okab 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/Rakefile +8 -0
- data/lib/okab/document.rb +235 -0
- data/lib/okab/font.rb +95 -0
- data/lib/okab/image.rb +183 -0
- data/lib/okab/page.rb +258 -0
- data/lib/okab/pdf/writer.rb +71 -0
- data/lib/okab/version.rb +5 -0
- data/lib/okab.rb +15 -0
- data/sig/okab.rbs +91 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8ccd04c824a28ca26ec41d2610efdcd2912a51adc0a6d9cb49fe26a8aa858751
|
|
4
|
+
data.tar.gz: 26a86a7f3b03e6406015ea712f8b2dac25909b947c22cc6d0d36d0edde7f58ff
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0e98d8261aff467b1e97f93242a73e6cacbc75d3a7d964e20da9f2fd1d900656342a31b72da32e715b593984e06c5ba08f9e5a4a70b4edddeb49b15c3cb269a8
|
|
7
|
+
data.tar.gz: 81794209eebf9639ecd66023f0d42872d2573906a27de87a6a3d05f36e8e619522a8ac0fbf4d12677cf58aae5c80350b836548c3a49b7458aa4861c3465f2163
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
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
|
|
9
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "okab"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Okab requires Ruby 3.2 or later and Alhena 0.3.0 or later.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
require "okab"
|
|
26
|
+
|
|
27
|
+
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")
|
|
35
|
+
document.outline("Report", page: page)
|
|
36
|
+
document.write("report.pdf")
|
|
37
|
+
```
|
|
38
|
+
|
|
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.
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
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:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
OKAB_TEST_FONT=/path/to/japanese-font.ttf bundle exec rake
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Okab
|
|
6
|
+
class Document
|
|
7
|
+
def initialize(title: nil, author: nil, creator: "Okab")
|
|
8
|
+
@metadata = {Title: title || "", Author: author || "", Creator: creator}
|
|
9
|
+
@pages, @fonts, @images, @opacities, @outlines = [], {}, {}, {}, []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def page(width:, height:)
|
|
13
|
+
page = Page.new(self, width: width, height: height)
|
|
14
|
+
@pages << page
|
|
15
|
+
yield page if block_given?
|
|
16
|
+
page
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def outline(title, page:, level: 0)
|
|
20
|
+
raise ArgumentError, "outline page belongs to another document" unless @pages.include?(page)
|
|
21
|
+
raise ArgumentError, "outline level must be a nonnegative integer" unless level.is_a?(Integer) && level >= 0
|
|
22
|
+
raise ArgumentError, "outline level cannot skip a parent" if level > (@outlines.last&.last || -1) + 1
|
|
23
|
+
|
|
24
|
+
@outlines << [String(title).dup.freeze, page, level]
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def owns_page?(page)
|
|
29
|
+
@pages.include?(page)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def embed_font(font, subset: true)
|
|
33
|
+
font = Font.new(font) if font.is_a?(Alhena::Font)
|
|
34
|
+
raise ArgumentError, "font must be an Okab::Font or Alhena::Font" unless font.is_a?(Font)
|
|
35
|
+
|
|
36
|
+
key = [font.object_id, !!subset]
|
|
37
|
+
@fonts[key] ||= EmbeddedFont.new(self, font, subset: subset)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def register_image(image)
|
|
41
|
+
raise ArgumentError, "expected an Okab::Image" unless image.is_a?(Image)
|
|
42
|
+
|
|
43
|
+
key = image.object_id
|
|
44
|
+
@images[key] ||= ["Im#{@images.length + 1}", image]
|
|
45
|
+
@images.fetch(key).first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def opacity_name(value)
|
|
49
|
+
raise ArgumentError, "opacity must be finite and between 0 and 1" unless value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value.between?(0, 1)
|
|
50
|
+
|
|
51
|
+
@opacities[value.to_f] ||= "GS#{@opacities.length + 1}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render
|
|
55
|
+
raise InvalidDocument, "document has no pages" if @pages.empty?
|
|
56
|
+
|
|
57
|
+
writer = PDF::Writer.new
|
|
58
|
+
pages_ref, catalog_ref, info_ref = writer.reserve, writer.reserve, writer.reserve
|
|
59
|
+
page_refs = @pages.map { writer.reserve }
|
|
60
|
+
font_resources = render_fonts(writer)
|
|
61
|
+
image_resources = render_images(writer)
|
|
62
|
+
opacity_resources = @opacities.to_h do |opacity, name|
|
|
63
|
+
[name, writer.add("<< /Type /ExtGState /ca #{number(opacity)} /CA #{number(opacity)} >>")]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@pages.zip(page_refs).each do |page, page_ref|
|
|
67
|
+
font_names = font_resources.to_h { |key, resource| [key.object_id, resource[:name]] }
|
|
68
|
+
content = page.content(fonts: font_names)
|
|
69
|
+
content_ref = writer.stream(content)
|
|
70
|
+
annotations = page.annotations.map { |annotation| annotation_object(writer, annotation, page_refs) }
|
|
71
|
+
fonts = font_resources.values.to_h { |font| [font[:name], font[:ref]] }
|
|
72
|
+
resources = "<< /Font #{resource_dictionary(fonts)} " \
|
|
73
|
+
"/XObject #{resource_dictionary(image_resources)} " \
|
|
74
|
+
"/ExtGState #{resource_dictionary(opacity_resources)} >>"
|
|
75
|
+
box = "[0 0 #{number(page.width)} #{number(page.height)}]"
|
|
76
|
+
annotation_refs = annotations.empty? ? "" : "/Annots [#{annotations.map { |ref| "#{ref} 0 R" }.join(' ')}]"
|
|
77
|
+
writer.set(page_ref, "<< /Type /Page /Parent #{pages_ref} 0 R /MediaBox #{box} /Resources #{resources} /Contents #{content_ref} 0 R #{annotation_refs} >>")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
writer.set(pages_ref, "<< /Type /Pages /Count #{@pages.length} /Kids [#{page_refs.map { |ref| "#{ref} 0 R" }.join(' ')}] >>")
|
|
81
|
+
outline_root = render_outlines(writer, page_refs)
|
|
82
|
+
catalog = "<< /Type /Catalog /Pages #{pages_ref} 0 R"
|
|
83
|
+
catalog << " /Outlines #{outline_root} 0 R /PageMode /UseOutlines" if outline_root
|
|
84
|
+
catalog << " >>"
|
|
85
|
+
writer.set(catalog_ref, catalog)
|
|
86
|
+
writer.set(info_ref, "<< #{@metadata.map { |key, value| "/#{key} #{pdf_text(value)}" }.join(' ')} >>")
|
|
87
|
+
writer.render(root: catalog_ref, info: info_ref)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write(path)
|
|
91
|
+
File.binwrite(path, render)
|
|
92
|
+
path
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def render_fonts(writer)
|
|
98
|
+
@fonts.values.each_with_index.filter_map do |embedded, index|
|
|
99
|
+
data, subset_face, subset_map, cff = embedded.subset_font
|
|
100
|
+
base_name = embedded.base_name(data)
|
|
101
|
+
font_file = writer.stream(data, cff ? "/Subtype /CIDFontType0C" : "/Length1 #{data.bytesize}")
|
|
102
|
+
bbox = subset_face.bbox.map { |value| scale(value, subset_face.units_per_em) }
|
|
103
|
+
ascent = scale(subset_face.ascent, subset_face.units_per_em)
|
|
104
|
+
descent = scale(subset_face.descent, subset_face.units_per_em)
|
|
105
|
+
font_file_key = cff ? "FontFile3" : "FontFile2"
|
|
106
|
+
descriptor = writer.add("<< /Type /FontDescriptor /FontName /#{base_name} /Flags 32 " \
|
|
107
|
+
"/FontBBox [#{bbox.join(' ')}] /ItalicAngle 0 /Ascent #{ascent} /Descent #{descent} " \
|
|
108
|
+
"/CapHeight #{ascent} /StemV 80 /#{font_file_key} #{font_file} 0 R >>")
|
|
109
|
+
cid_to_gid = +"\0\0".b
|
|
110
|
+
widths, mappings = [], []
|
|
111
|
+
embedded.characters.each do |cid, (character, old_gid)|
|
|
112
|
+
next if cid.zero?
|
|
113
|
+
|
|
114
|
+
cid_to_gid << [subset_map.fetch(old_gid)].pack("n") unless cff
|
|
115
|
+
widths << "#{cid} [#{scale(embedded.face.advance_width(old_gid), embedded.face.units_per_em)}]"
|
|
116
|
+
mappings << "<#{format('%04X', cid)}> #{PDF::Encoding.unicode_hex(character)}"
|
|
117
|
+
end
|
|
118
|
+
cid_map = cff ? "" : " /CIDToGIDMap #{writer.stream(cid_to_gid)} 0 R"
|
|
119
|
+
subtype = cff ? "CIDFontType0" : "CIDFontType2"
|
|
120
|
+
cid_font = writer.add("<< /Type /Font /Subtype /#{subtype} /BaseFont /#{base_name} " \
|
|
121
|
+
"/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> " \
|
|
122
|
+
"/FontDescriptor #{descriptor} 0 R /DW 1000 /W [#{widths.join(' ')}]#{cid_map} >>")
|
|
123
|
+
cmap = to_unicode_cmap(mappings)
|
|
124
|
+
cmap_ref = writer.stream(cmap)
|
|
125
|
+
type0 = writer.add("<< /Type /Font /Subtype /Type0 /BaseFont /#{base_name} /Encoding /Identity-H " \
|
|
126
|
+
"/DescendantFonts [#{cid_font} 0 R] /ToUnicode #{cmap_ref} 0 R >>")
|
|
127
|
+
[embedded, {name: "F#{index + 1}", ref: type0}]
|
|
128
|
+
end.to_h
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def render_images(writer)
|
|
132
|
+
@images.values.to_h do |name, image|
|
|
133
|
+
smask = image.alpha && writer.stream(Zlib::Deflate.deflate(image.alpha),
|
|
134
|
+
"/Type /XObject /Subtype /Image /Width #{image.width} /Height #{image.height} " \
|
|
135
|
+
"/ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode")
|
|
136
|
+
filter = image.filter == :dct ? "/Filter /DCTDecode" : "/Filter /FlateDecode"
|
|
137
|
+
data = image.filter == :dct ? image.data : Zlib::Deflate.deflate(image.data)
|
|
138
|
+
color_space = image.color_space == :gray ? "/DeviceGray" : "/DeviceRGB"
|
|
139
|
+
decode = image.decode_parms ? "/DecodeParms << #{image.decode_parms} >>" : ""
|
|
140
|
+
mask = smask ? "/SMask #{smask} 0 R" : ""
|
|
141
|
+
ref = writer.stream(data, "/Type /XObject /Subtype /Image /Width #{image.width} /Height #{image.height} " \
|
|
142
|
+
"/ColorSpace #{color_space} /BitsPerComponent 8 #{filter} #{decode} #{mask}")
|
|
143
|
+
[name, ref]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render_outlines(writer, page_refs)
|
|
148
|
+
return nil if @outlines.empty?
|
|
149
|
+
|
|
150
|
+
root = writer.reserve
|
|
151
|
+
top, parents = [], []
|
|
152
|
+
@outlines.each do |title, page, requested_level|
|
|
153
|
+
parents = parents.take(requested_level)
|
|
154
|
+
siblings = requested_level.zero? ? top : parents.fetch(requested_level - 1)[:children]
|
|
155
|
+
item = {title: title, page: page_refs.fetch(@pages.index(page)), children: []}
|
|
156
|
+
siblings << item
|
|
157
|
+
parents[requested_level] = item
|
|
158
|
+
end
|
|
159
|
+
refs = {}
|
|
160
|
+
allocate = lambda do |siblings|
|
|
161
|
+
siblings.each { |item| refs[item.object_id] = writer.reserve; allocate.call(item[:children]) unless item[:children].empty? }
|
|
162
|
+
end
|
|
163
|
+
allocate.call(top)
|
|
164
|
+
write_items = lambda do |siblings, parent_ref|
|
|
165
|
+
siblings.each_with_index do |item, index|
|
|
166
|
+
ref = refs.fetch(item.object_id)
|
|
167
|
+
sibling_refs = siblings.map { |sibling| refs.fetch(sibling.object_id) }
|
|
168
|
+
values = "/Title #{pdf_text(item[:title])} /Parent #{parent_ref} 0 R /Dest [#{item[:page]} 0 R /Fit]"
|
|
169
|
+
values << " /Prev #{sibling_refs[index - 1]} 0 R" if index.positive?
|
|
170
|
+
values << " /Next #{sibling_refs[index + 1]} 0 R" if index + 1 < sibling_refs.length
|
|
171
|
+
unless item[:children].empty?
|
|
172
|
+
child_refs = item[:children].map { |child| refs.fetch(child.object_id) }
|
|
173
|
+
values << " /First #{child_refs.first} 0 R /Last #{child_refs.last} 0 R /Count #{descendant_count(item)}"
|
|
174
|
+
end
|
|
175
|
+
writer.set(ref, "<< #{values} >>")
|
|
176
|
+
write_items.call(item[:children], ref) unless item[:children].empty?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
write_items.call(top, root)
|
|
180
|
+
top_refs = top.map { |item| refs.fetch(item.object_id) }
|
|
181
|
+
count = top.sum { |item| 1 + descendant_count(item) }
|
|
182
|
+
writer.set(root, "<< /Type /Outlines /First #{top_refs.first} 0 R /Last #{top_refs.last} 0 R /Count #{count} >>")
|
|
183
|
+
root
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def descendant_count(item)
|
|
187
|
+
item[:children].sum { |child| 1 + descendant_count(child) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def annotation_object(writer, annotation, page_refs)
|
|
191
|
+
x, y, width, height, type, target = annotation
|
|
192
|
+
rect = "[#{number(x)} #{number(y)} #{number(x + width)} #{number(y + height)}]"
|
|
193
|
+
action = if type == :uri
|
|
194
|
+
"/A << /S /URI /URI #{pdf_text(target)} >>"
|
|
195
|
+
else
|
|
196
|
+
"/Dest [#{page_refs.fetch(@pages.index(target))} 0 R /Fit]"
|
|
197
|
+
end
|
|
198
|
+
writer.add("<< /Type /Annot /Subtype /Link /Rect #{rect} /Border [0 0 0] #{action} >>")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def resource_dictionary(resources)
|
|
202
|
+
entries = resources.map { |name, ref| "/#{PDF::Encoding.name(name)} #{ref} 0 R" }
|
|
203
|
+
"<< #{entries.join(' ')} >>"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def to_unicode_cmap(mappings)
|
|
207
|
+
chunks = mappings.each_slice(100).map do |entries|
|
|
208
|
+
"#{entries.length} beginbfchar\n#{entries.join("\n")}\nendbfchar\n"
|
|
209
|
+
end.join
|
|
210
|
+
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n" \
|
|
211
|
+
"/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n" \
|
|
212
|
+
"/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n" \
|
|
213
|
+
"#{chunks}endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def pdf_text(value)
|
|
217
|
+
text = String(value).encode(::Encoding::UTF_8)
|
|
218
|
+
if text.ascii_only?
|
|
219
|
+
"(#{text.gsub(/[\\()]/) { |character| "\\#{character}" }.gsub(/[\x00-\x1f\x7f]/) { |character| format('\\%03o', character.ord) }})"
|
|
220
|
+
else
|
|
221
|
+
PDF::Encoding.hex("\xFE\xFF".b + text.encode(::Encoding::UTF_16BE).b)
|
|
222
|
+
end
|
|
223
|
+
rescue EncodingError
|
|
224
|
+
raise ArgumentError, "PDF text must be valid UTF-8"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def scale(value, units_per_em)
|
|
228
|
+
(value.to_f * 1000 / units_per_em).round
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def number(value)
|
|
232
|
+
PDF::Encoding.number(value)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
data/lib/okab/font.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Okab
|
|
6
|
+
class Font
|
|
7
|
+
attr_reader :face
|
|
8
|
+
|
|
9
|
+
def self.load(path) = new(Alhena::Font.open(path))
|
|
10
|
+
def self.embed(document, font, subset: true) = document.embed_font(font, subset: subset)
|
|
11
|
+
|
|
12
|
+
def initialize(face)
|
|
13
|
+
raise ArgumentError, "expected an Alhena::Font" unless face.is_a?(Alhena::Font)
|
|
14
|
+
|
|
15
|
+
@face = face
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def measure(text, size:)
|
|
19
|
+
raise ArgumentError, "text must be valid UTF-8" unless text.is_a?(String) && text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
20
|
+
raise ArgumentError, "size must be finite and positive" unless size.is_a?(Numeric) && size.finite? && size.positive?
|
|
21
|
+
|
|
22
|
+
@face.glyph_ids(text).sum { |glyph| @face.advance_width(glyph) } * size.to_f / @face.units_per_em
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class EmbeddedFont
|
|
27
|
+
attr_reader :document, :font, :subset
|
|
28
|
+
|
|
29
|
+
def initialize(document, font, subset: true)
|
|
30
|
+
@document, @font, @subset = document, font, !!subset
|
|
31
|
+
@cid_by_character, @characters = {}, {0 => ["\u0000", 0]}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def face = @font.face
|
|
35
|
+
def measure(text, size:) = @font.measure(text, size: size)
|
|
36
|
+
|
|
37
|
+
def encode(string)
|
|
38
|
+
codepoints = string.codepoints
|
|
39
|
+
cids = []
|
|
40
|
+
codepoints.each_with_index do |codepoint, index|
|
|
41
|
+
next if variation_selector?(codepoint)
|
|
42
|
+
|
|
43
|
+
selector = variation_selector?(codepoints[index + 1]) ? codepoints[index + 1] : nil
|
|
44
|
+
glyph = face.glyph_id(codepoint, variation_selector: selector)
|
|
45
|
+
glyph = face.glyph_id(codepoint) if glyph.zero? && selector
|
|
46
|
+
character = [codepoint, *([selector] if selector)].pack("U*")
|
|
47
|
+
key = [character, glyph]
|
|
48
|
+
cid = @cid_by_character[key]
|
|
49
|
+
unless cid
|
|
50
|
+
cid = @characters.length
|
|
51
|
+
raise ArgumentError, "a PDF font cannot encode more than 65,535 characters" if cid > 65_535
|
|
52
|
+
|
|
53
|
+
@cid_by_character[key] = cid
|
|
54
|
+
@characters[cid] = [character, glyph]
|
|
55
|
+
end
|
|
56
|
+
cids << cid
|
|
57
|
+
end
|
|
58
|
+
cids.pack("n*")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def glyph_ids
|
|
62
|
+
@characters.values.map(&:last).uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def subset_font
|
|
66
|
+
if face.cff?
|
|
67
|
+
raise Alhena::UnsupportedFont, "CID CFF embedding requires subset: true" unless @subset
|
|
68
|
+
|
|
69
|
+
data = Alhena::Subset.build_cid(face, @characters.values.map(&:last))
|
|
70
|
+
return [data, face, {}, true]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
selected = @subset ? glyph_ids : (0...face.glyph_count).to_a
|
|
74
|
+
data = Alhena::Subset.build(face, selected)
|
|
75
|
+
subset_face = Alhena::Font.new(data)
|
|
76
|
+
old_ids = Alhena::Subset.closure(face, [0, *selected].uniq)
|
|
77
|
+
[data, subset_face, old_ids.each_with_index.to_h, false]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def characters = @characters
|
|
81
|
+
|
|
82
|
+
def base_name(data)
|
|
83
|
+
prefix = Digest::SHA256.hexdigest(data).upcase[0, 6].tr("0-9", "A-J")
|
|
84
|
+
family = face.family.to_s.gsub(/[^A-Za-z0-9]/, "")
|
|
85
|
+
"#{prefix}+#{family.empty? ? 'EmbeddedFont' : family}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def variation_selector?(codepoint)
|
|
91
|
+
codepoint && ((0xfe00..0xfe0f).cover?(codepoint) || (0xe0100..0xe01ef).cover?(codepoint))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/okab/image.rb
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Okab
|
|
6
|
+
class Image
|
|
7
|
+
# ponytail: cap decoded memory at 50 MP; add streaming raster output if larger images are needed.
|
|
8
|
+
MAX_PIXELS = 50_000_000
|
|
9
|
+
private_constant :MAX_PIXELS
|
|
10
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
|
|
11
|
+
private_constant :PNG_SIGNATURE
|
|
12
|
+
|
|
13
|
+
attr_reader :width, :height, :color_space, :data, :alpha, :filter, :decode_parms
|
|
14
|
+
|
|
15
|
+
def self.decode(bytes, format: :auto)
|
|
16
|
+
raise ArgumentError, "image data must be a String" unless bytes.is_a?(String)
|
|
17
|
+
format = :png if format == :auto && bytes.start_with?(PNG_SIGNATURE)
|
|
18
|
+
format = :jpeg if format == :auto && bytes.start_with?("\xff\xd8".b)
|
|
19
|
+
raise ArgumentError, "unsupported image format" unless %i[png jpeg].include?(format)
|
|
20
|
+
|
|
21
|
+
format == :png ? decode_png(bytes) : decode_jpeg(bytes)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(width:, height:, color_space:, data:, alpha: nil, filter: nil, decode_parms: nil)
|
|
25
|
+
raise InvalidDocument, "invalid image dimensions" unless width.is_a?(Integer) && height.is_a?(Integer) &&
|
|
26
|
+
width.positive? && height.positive? && width * height <= MAX_PIXELS
|
|
27
|
+
raise InvalidDocument, "invalid image color space" unless %i[gray rgb].include?(color_space)
|
|
28
|
+
raise InvalidDocument, "invalid image data" unless data.is_a?(String)
|
|
29
|
+
if filter == :dct
|
|
30
|
+
raise InvalidDocument, "invalid JPEG data" unless data.start_with?("\xff\xd8".b) && alpha.nil?
|
|
31
|
+
raise InvalidDocument, "invalid JPEG decode parameters" unless [nil, "/ColorTransform 1"].include?(decode_parms)
|
|
32
|
+
else
|
|
33
|
+
channels = color_space == :gray ? 1 : 3
|
|
34
|
+
raise InvalidDocument, "invalid pixel data length" unless filter.nil? && decode_parms.nil? && data.bytesize == width * height * channels
|
|
35
|
+
raise InvalidDocument, "invalid alpha data length" if alpha && (!alpha.is_a?(String) || alpha.bytesize != width * height)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@width, @height, @color_space = width, height, color_space
|
|
39
|
+
@data, @alpha, @filter, @decode_parms = data.b.freeze, alpha&.b&.freeze, filter, decode_parms
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.decode_png(bytes)
|
|
44
|
+
at, header, palette, transparency, compressed = PNG_SIGNATURE.bytesize, nil, nil, nil, +"".b
|
|
45
|
+
while at < bytes.bytesize
|
|
46
|
+
raise InvalidDocument, "truncated PNG chunk" if at + 12 > bytes.bytesize
|
|
47
|
+
length = bytes.byteslice(at, 4).unpack1("N")
|
|
48
|
+
type = bytes.byteslice(at + 4, 4)
|
|
49
|
+
raise InvalidDocument, "invalid PNG chunk length" if length > bytes.bytesize - at - 12
|
|
50
|
+
payload = bytes.byteslice(at + 8, length)
|
|
51
|
+
checksum = bytes.byteslice(at + 8 + length, 4).unpack1("N")
|
|
52
|
+
raise InvalidDocument, "PNG checksum mismatch" unless Zlib.crc32(type + payload) == checksum
|
|
53
|
+
case type
|
|
54
|
+
when "IHDR" then header = payload
|
|
55
|
+
when "PLTE" then palette = payload
|
|
56
|
+
when "tRNS" then transparency = payload
|
|
57
|
+
when "IDAT" then compressed << payload
|
|
58
|
+
when "IEND" then break
|
|
59
|
+
end
|
|
60
|
+
at += length + 12
|
|
61
|
+
end
|
|
62
|
+
raise InvalidDocument, "missing PNG header" unless header&.bytesize == 13
|
|
63
|
+
width, height, depth, type, compression, filtering, interlace = header.unpack("NNC5")
|
|
64
|
+
raise InvalidDocument, "unsupported PNG format" unless depth == 8 && compression.zero? && filtering.zero? && interlace.zero?
|
|
65
|
+
# ponytail: only 8-bit non-interlaced PNG is decoded; packed, 16-bit, and Adam7 modes add separate row walkers.
|
|
66
|
+
raise InvalidDocument, "invalid PNG dimensions" unless width.positive? && height.positive? && width * height <= MAX_PIXELS
|
|
67
|
+
channels = {0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4}[type]
|
|
68
|
+
raise InvalidDocument, "unsupported PNG color type" unless channels
|
|
69
|
+
raise InvalidDocument, "indexed PNG has no palette" if type == 3 && (!palette || palette.empty? || palette.bytesize % 3 != 0)
|
|
70
|
+
decoded = inflate_bounded(compressed, (width * channels + 1) * height)
|
|
71
|
+
row_bytes, bpp = width * channels, channels
|
|
72
|
+
prior = "\0" * row_bytes
|
|
73
|
+
rgb, alpha = +"".b, +"".b
|
|
74
|
+
height.times do |row|
|
|
75
|
+
offset = row * (row_bytes + 1)
|
|
76
|
+
filter = decoded.getbyte(offset)
|
|
77
|
+
current = decoded.byteslice(offset + 1, row_bytes).bytes
|
|
78
|
+
prior_bytes = prior.bytes
|
|
79
|
+
current.each_index do |index|
|
|
80
|
+
left = index >= bpp ? current[index - bpp] : 0
|
|
81
|
+
above = prior_bytes[index]
|
|
82
|
+
upper_left = index >= bpp ? prior_bytes[index - bpp] : 0
|
|
83
|
+
current[index] = (current[index] + case filter
|
|
84
|
+
when 0 then 0
|
|
85
|
+
when 1 then left
|
|
86
|
+
when 2 then above
|
|
87
|
+
when 3 then (left + above) / 2
|
|
88
|
+
when 4 then paeth(left, above, upper_left)
|
|
89
|
+
else raise InvalidDocument, "invalid PNG filter"
|
|
90
|
+
end) & 255
|
|
91
|
+
end
|
|
92
|
+
prior = current.pack("C*")
|
|
93
|
+
width.times do |column|
|
|
94
|
+
pixel = column * channels
|
|
95
|
+
case type
|
|
96
|
+
when 0
|
|
97
|
+
gray = current[pixel]
|
|
98
|
+
rgb << gray.chr * 3
|
|
99
|
+
alpha << (transparency&.unpack1("n") == gray ? 0 : 255)
|
|
100
|
+
when 2
|
|
101
|
+
values = current[pixel, 3]
|
|
102
|
+
rgb << values.pack("C*")
|
|
103
|
+
alpha << (transparency && transparency.unpack("n*") == values ? 0 : 255)
|
|
104
|
+
when 3
|
|
105
|
+
index = current[pixel]
|
|
106
|
+
raise InvalidDocument, "PNG palette index out of range" if index * 3 + 2 >= palette.bytesize
|
|
107
|
+
rgb << palette.byteslice(index * 3, 3)
|
|
108
|
+
alpha << (transparency&.getbyte(index) || 255)
|
|
109
|
+
when 4
|
|
110
|
+
rgb << current[pixel].chr * 3
|
|
111
|
+
alpha << current[pixel + 1]
|
|
112
|
+
when 6
|
|
113
|
+
rgb << current[pixel, 3].pack("C*")
|
|
114
|
+
alpha << current[pixel + 3]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
alpha = nil if alpha.each_byte.all? { |value| value == 255 }
|
|
119
|
+
new(width: width, height: height, color_space: :rgb, data: rgb, alpha: alpha)
|
|
120
|
+
rescue Zlib::Error => error
|
|
121
|
+
raise InvalidDocument, "invalid PNG compression: #{error.message}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.decode_jpeg(bytes)
|
|
125
|
+
raise InvalidDocument, "invalid JPEG marker" unless bytes.start_with?("\xff\xd8".b)
|
|
126
|
+
at, frame = 2, nil
|
|
127
|
+
while at + 4 <= bytes.bytesize
|
|
128
|
+
at += 1 while at < bytes.bytesize && bytes.getbyte(at) != 0xff
|
|
129
|
+
at += 1 while at < bytes.bytesize && bytes.getbyte(at) == 0xff
|
|
130
|
+
marker = bytes.getbyte(at)
|
|
131
|
+
at += 1
|
|
132
|
+
next if [0xd8, 0xd9, 0x01, *0xd0..0xd7].include?(marker)
|
|
133
|
+
length = bytes.byteslice(at, 2)&.unpack1("n")
|
|
134
|
+
raise InvalidDocument, "truncated JPEG segment" unless length && length >= 2 && at + length <= bytes.bytesize
|
|
135
|
+
# ponytail: support Huffman DCT SOF modes only; lossless/arithmetic JPEG needs another PDF image filter.
|
|
136
|
+
if [0xc0, 0xc1, 0xc2].include?(marker)
|
|
137
|
+
precision, height, width, components = bytes.byteslice(at + 2, 6).unpack("CnnC")
|
|
138
|
+
raise InvalidDocument, "unsupported JPEG component count" unless [1, 3].include?(components)
|
|
139
|
+
raise InvalidDocument, "unsupported JPEG precision" unless precision == 8
|
|
140
|
+
frame = [width, height, components]
|
|
141
|
+
elsif marker == 0xda
|
|
142
|
+
raise InvalidDocument, "JPEG frame header not found" unless frame
|
|
143
|
+
eoi = bytes.rindex("\xff\xd9".b)
|
|
144
|
+
raise InvalidDocument, "JPEG image data is incomplete" unless eoi && eoi >= at + length
|
|
145
|
+
width, height, components = frame
|
|
146
|
+
return new(width: width, height: height, color_space: components == 1 ? :gray : :rgb,
|
|
147
|
+
data: bytes, filter: :dct, decode_parms: components == 3 ? "/ColorTransform 1" : nil)
|
|
148
|
+
end
|
|
149
|
+
at += length
|
|
150
|
+
end
|
|
151
|
+
raise InvalidDocument, frame ? "JPEG scan header not found" : "JPEG frame header not found"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.inflate_bounded(bytes, expected)
|
|
155
|
+
inflater, output = Zlib::Inflate.new, +"".b
|
|
156
|
+
offset = 0
|
|
157
|
+
while offset < bytes.bytesize
|
|
158
|
+
output << inflater.inflate(bytes.byteslice(offset, 4096))
|
|
159
|
+
raise InvalidDocument, "PNG data exceeds declared dimensions" if output.bytesize > expected
|
|
160
|
+
offset += 4096
|
|
161
|
+
end
|
|
162
|
+
output << inflater.finish
|
|
163
|
+
raise InvalidDocument, "PNG data length does not match dimensions" unless output.bytesize == expected
|
|
164
|
+
output
|
|
165
|
+
ensure
|
|
166
|
+
inflater&.close
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.paeth(left, above, upper_left)
|
|
170
|
+
estimate = left + above - upper_left
|
|
171
|
+
left_distance = (estimate - left).abs
|
|
172
|
+
above_distance = (estimate - above).abs
|
|
173
|
+
corner_distance = (estimate - upper_left).abs
|
|
174
|
+
if left_distance <= above_distance && left_distance <= corner_distance
|
|
175
|
+
left
|
|
176
|
+
elsif above_distance <= corner_distance
|
|
177
|
+
above
|
|
178
|
+
else
|
|
179
|
+
upper_left
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/okab/page.rb
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Okab
|
|
6
|
+
class Path
|
|
7
|
+
attr_reader :commands
|
|
8
|
+
|
|
9
|
+
def initialize = (@commands = [])
|
|
10
|
+
def move_to(x, y) = command("#{number(x)} #{number(y)} m")
|
|
11
|
+
def line_to(x, y) = command("#{number(x)} #{number(y)} l")
|
|
12
|
+
def curve_to(x1, y1, x2, y2, x3, y3) = command("#{[x1, y1, x2, y2, x3, y3].map { |v| number(v) }.join(' ')} c")
|
|
13
|
+
def close = command("h")
|
|
14
|
+
def rect(x, y, width, height) = command("#{[x, y, width, height].map { |v| number(v) }.join(' ')} re")
|
|
15
|
+
|
|
16
|
+
def ellipse(x, y, width, height)
|
|
17
|
+
k = 0.5522847498307936
|
|
18
|
+
cx, cy, rx, ry = x + width / 2.0, y + height / 2.0, width / 2.0, height / 2.0
|
|
19
|
+
move_to(cx + rx, cy)
|
|
20
|
+
curve_to(cx + rx, cy + k * ry, cx + k * rx, cy + ry, cx, cy + ry)
|
|
21
|
+
curve_to(cx - k * rx, cy + ry, cx - rx, cy + k * ry, cx - rx, cy)
|
|
22
|
+
curve_to(cx - rx, cy - k * ry, cx - k * rx, cy - ry, cx, cy - ry)
|
|
23
|
+
curve_to(cx + k * rx, cy - ry, cx + rx, cy - k * ry, cx + rx, cy)
|
|
24
|
+
close
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def command(value)
|
|
30
|
+
@commands << value
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def number(value)
|
|
35
|
+
raise ArgumentError, "coordinates must be finite numbers" unless value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite?
|
|
36
|
+
PDF::Encoding.number(value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Page
|
|
41
|
+
attr_reader :width, :height, :operations, :annotations, :document
|
|
42
|
+
|
|
43
|
+
def initialize(document, width:, height:)
|
|
44
|
+
@document, @width, @height = document, Float(width), Float(height)
|
|
45
|
+
raise ArgumentError, "page dimensions must be finite and positive" unless [@width, @height].all? { |value| value.finite? && value.positive? }
|
|
46
|
+
@operations, @annotations, @open_clips = [], [], 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def text(string, x:, y:, font:, size:, color: [0, 0, 0], tracking: 0, bold: false, italic: false)
|
|
50
|
+
validate_text(string)
|
|
51
|
+
size, tracking = finite(size, "font size"), finite(tracking, "tracking")
|
|
52
|
+
raise ArgumentError, "font size must be positive" unless size.positive?
|
|
53
|
+
raise ArgumentError, "bold and italic must be true or false" unless [bold, italic].all? { |value| value == true || value == false }
|
|
54
|
+
embedded = @document.embed_font(font)
|
|
55
|
+
encoded = embedded.encode(string)
|
|
56
|
+
@operations << [:text, finite(x, "x"), finite(y, "y"), embedded, encoded, size, rgb(color), tracking, bold, italic]
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def text_block(string, x:, y:, width:, font:, size:, line_height:, align: :left, color: [0, 0, 0], tracking: 0)
|
|
61
|
+
validate_text(string)
|
|
62
|
+
x, y = finite(x, "x"), finite(y, "y")
|
|
63
|
+
width, size, line_height = finite(width, "width"), finite(size, "font size"), finite(line_height, "line height")
|
|
64
|
+
raise ArgumentError, "text block dimensions must be positive" unless width.positive? && size.positive? && line_height.positive?
|
|
65
|
+
raise ArgumentError, "align must be left, center, right, or justify" unless %i[left center right justify].include?(align)
|
|
66
|
+
face = font.is_a?(EmbeddedFont) ? font.font : normalize_font(font)
|
|
67
|
+
lines = wrap_lines(string, face, size, width)
|
|
68
|
+
lines.each_with_index do |line, index|
|
|
69
|
+
line_width = face.measure(line, size: size)
|
|
70
|
+
offset = case align
|
|
71
|
+
when :center then (width - line_width) / 2.0
|
|
72
|
+
when :right then width - line_width
|
|
73
|
+
else 0
|
|
74
|
+
end
|
|
75
|
+
extra = align == :justify && index < lines.length - 1 ? (width - line_width) / [line.length, 1].max : 0
|
|
76
|
+
text(line, x: x + offset, y: y - index * line_height, font: face, size: size,
|
|
77
|
+
color: color, tracking: tracking + extra)
|
|
78
|
+
end
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def move_to(x, y) = raw(Path.new.move_to(x, y).commands.last)
|
|
83
|
+
def line_to(x, y) = raw(Path.new.line_to(x, y).commands.last)
|
|
84
|
+
def curve_to(x1, y1, x2, y2, x3, y3) = raw(Path.new.curve_to(x1, y1, x2, y2, x3, y3).commands.last)
|
|
85
|
+
def close = raw("h")
|
|
86
|
+
def rect(x, y, width, height) = raw(Path.new.rect(x, y, width, height).commands.last)
|
|
87
|
+
|
|
88
|
+
def ellipse(x, y, width, height)
|
|
89
|
+
path = Path.new.ellipse(*[x, y, width, height].map { |value| finite(value, "ellipse bounds") })
|
|
90
|
+
raw(path.commands.join("\n"))
|
|
91
|
+
end
|
|
92
|
+
|
|
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")
|
|
95
|
+
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
|
+
|
|
97
|
+
def clip
|
|
98
|
+
raise ArgumentError, "clip requires a block" unless block_given?
|
|
99
|
+
path = Path.new
|
|
100
|
+
yield path
|
|
101
|
+
raise ArgumentError, "clip path must not be empty" if path.commands.empty?
|
|
102
|
+
@operations << [:raw, "q\n#{path.commands.join("\n")}\nW n"]
|
|
103
|
+
@open_clips += 1
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def transform(a, b, c, d, e, f)
|
|
108
|
+
raise ArgumentError, "transform requires a block" unless block_given?
|
|
109
|
+
values = [a, b, c, d, e, f].map { |value| finite(value, 'transform') }
|
|
110
|
+
start = @operations.length
|
|
111
|
+
@operations << [:raw, "q\n#{values.map { |value| PDF::Encoding.number(value) }.join(' ')} cm"]
|
|
112
|
+
begin
|
|
113
|
+
yield self
|
|
114
|
+
rescue StandardError
|
|
115
|
+
@operations.slice!(start..)
|
|
116
|
+
raise
|
|
117
|
+
end
|
|
118
|
+
@operations << [:raw, "Q"]
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def opacity(value)
|
|
123
|
+
raise ArgumentError, "opacity requires a block" unless block_given?
|
|
124
|
+
name = @document.opacity_name(value)
|
|
125
|
+
start = @operations.length
|
|
126
|
+
@operations << [:raw, "q\n/#{name} gs"]
|
|
127
|
+
begin
|
|
128
|
+
yield self
|
|
129
|
+
rescue StandardError
|
|
130
|
+
@operations.slice!(start..)
|
|
131
|
+
raise
|
|
132
|
+
end
|
|
133
|
+
@operations << [:raw, "Q"]
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def image(data, x:, y:, width:, height:, format: :auto)
|
|
138
|
+
image = data.is_a?(Image) ? data : Image.decode(data, format: format)
|
|
139
|
+
name = @document.register_image(image)
|
|
140
|
+
@operations << [:image, name, finite(x, "x"), finite(y, "y"), finite(width, "width"), finite(height, "height")]
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def link(bounds, uri: nil, page: nil)
|
|
145
|
+
raise ArgumentError, "provide either uri or page" if (!!uri == !!page)
|
|
146
|
+
x, y, width, height = normalize_bounds(bounds)
|
|
147
|
+
action = if uri
|
|
148
|
+
parsed = URI.parse(uri.to_s)
|
|
149
|
+
valid = (parsed.is_a?(URI::HTTP) && parsed.host && %w[http https].include?(parsed.scheme)) ||
|
|
150
|
+
(parsed.is_a?(URI::MailTo) && !parsed.to.empty?)
|
|
151
|
+
raise ArgumentError, "link URI must use http, https, or mailto" unless valid
|
|
152
|
+
[:uri, uri.to_s.freeze]
|
|
153
|
+
else
|
|
154
|
+
raise ArgumentError, "destination page belongs to another document" unless page.is_a?(Page) && @document.owns_page?(page)
|
|
155
|
+
[:page, page]
|
|
156
|
+
end
|
|
157
|
+
@annotations << [x, y, width, height, *action]
|
|
158
|
+
self
|
|
159
|
+
rescue URI::InvalidURIError
|
|
160
|
+
raise ArgumentError, "invalid link URI"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def content(fonts:)
|
|
164
|
+
output = +"".b
|
|
165
|
+
@operations.each do |operation|
|
|
166
|
+
case operation[0]
|
|
167
|
+
when :raw then output << operation[1].b << "\n".b
|
|
168
|
+
when :text
|
|
169
|
+
_, x, y, font, encoded, size, color, tracking, bold, italic = operation
|
|
170
|
+
output << "#{color.join(' ')} rg\n"
|
|
171
|
+
if bold
|
|
172
|
+
output << "#{color.join(' ')} RG\n#{PDF::Encoding.number((size * 0.035).round(4))} w\n"
|
|
173
|
+
end
|
|
174
|
+
matrix = italic ? "1 0 0.2 1 #{PDF::Encoding.number(x)} #{PDF::Encoding.number(y)} Tm" : "#{PDF::Encoding.number(x)} #{PDF::Encoding.number(y)} Td"
|
|
175
|
+
output << "BT /#{fonts.fetch(font.object_id)} #{PDF::Encoding.number(size)} Tf #{PDF::Encoding.number(tracking)} Tc #{bold ? 2 : 0} Tr #{matrix} #{PDF::Encoding.hex(encoded)} Tj ET\n".b
|
|
176
|
+
when :image
|
|
177
|
+
_, name, x, y, width, height = operation
|
|
178
|
+
output << "q\n#{PDF::Encoding.number(width)} 0 0 #{PDF::Encoding.number(height)} #{PDF::Encoding.number(x)} #{PDF::Encoding.number(y)} cm\n/#{name} Do\nQ\n".b
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
@open_clips.times { output << "Q\n".b }
|
|
182
|
+
output
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def raw(value)
|
|
188
|
+
@operations << [:raw, value]
|
|
189
|
+
self
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_text(string)
|
|
193
|
+
raise ArgumentError, "text must be valid UTF-8" unless string.is_a?(String) && string.encoding == Encoding::UTF_8 && string.valid_encoding?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def finite(value, label)
|
|
197
|
+
raise ArgumentError, "#{label} must be finite" unless value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite?
|
|
198
|
+
value = value.to_f
|
|
199
|
+
raise ArgumentError, "#{label} must be finite" unless value.finite?
|
|
200
|
+
value
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def positive(value, label)
|
|
204
|
+
value = finite(value, label)
|
|
205
|
+
raise ArgumentError, "#{label} must be positive" unless value.positive?
|
|
206
|
+
value
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def rgb(color)
|
|
210
|
+
valid = color.is_a?(Array) && color.length == 3 && color.all? { |value| value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value.between?(0, 1) }
|
|
211
|
+
raise ArgumentError, "color must contain three finite values between 0 and 1" unless valid
|
|
212
|
+
color.map { |value| PDF::Encoding.number(value) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalize_bounds(bounds)
|
|
216
|
+
values = if bounds.is_a?(Array)
|
|
217
|
+
bounds
|
|
218
|
+
elsif %i[x y width height].all? { |name| bounds.respond_to?(name) }
|
|
219
|
+
%i[x y width height].map { |name| bounds.public_send(name) }
|
|
220
|
+
end
|
|
221
|
+
raise ArgumentError, "bounds must be [x, y, width, height]" unless values.is_a?(Array) && values.length == 4
|
|
222
|
+
x, y, width, height = values.map { |value| finite(value, "bounds") }
|
|
223
|
+
raise ArgumentError, "bounds dimensions must be positive" unless width.positive? && height.positive?
|
|
224
|
+
[x, y, width, height]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def normalize_font(font)
|
|
228
|
+
return font if font.is_a?(Font)
|
|
229
|
+
return Font.new(font) if font.is_a?(Alhena::Font)
|
|
230
|
+
raise ArgumentError, "font must be an Okab::Font or Alhena::Font"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def wrap_lines(string, font, size, width)
|
|
234
|
+
string.split("\n", -1).flat_map do |paragraph|
|
|
235
|
+
tokens = paragraph.split(/(?<=\s)/)
|
|
236
|
+
lines, line = [], +""
|
|
237
|
+
tokens.each do |token|
|
|
238
|
+
if !line.empty? && font.measure(line + token, size: size) > width
|
|
239
|
+
lines << line.rstrip
|
|
240
|
+
line = +""
|
|
241
|
+
end
|
|
242
|
+
if font.measure(token, size: size) > width
|
|
243
|
+
token.grapheme_clusters.each do |cluster|
|
|
244
|
+
lines << line if !line.empty? && font.measure(line + cluster, size: size) > width
|
|
245
|
+
line = +"" if !line.empty? && font.measure(line + cluster, size: size) > width
|
|
246
|
+
line << cluster
|
|
247
|
+
end
|
|
248
|
+
else
|
|
249
|
+
line << token
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
lines << line.rstrip unless line.empty?
|
|
253
|
+
lines << "" if paragraph.empty? && lines.empty?
|
|
254
|
+
lines
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Okab
|
|
6
|
+
module PDF
|
|
7
|
+
class Writer
|
|
8
|
+
def initialize
|
|
9
|
+
@objects = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reserve
|
|
13
|
+
@objects << nil
|
|
14
|
+
@objects.length
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add(value)
|
|
18
|
+
@objects << value.b
|
|
19
|
+
@objects.length
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set(reference, value)
|
|
23
|
+
raise InvalidDocument, "invalid PDF object reference" unless reference.between?(1, @objects.length)
|
|
24
|
+
raise InvalidDocument, "PDF object already assigned" if @objects[reference - 1]
|
|
25
|
+
|
|
26
|
+
@objects[reference - 1] = value.b
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stream(data, dictionary = "")
|
|
30
|
+
add("<< /Length #{data.bytesize} #{dictionary} >>\nstream\n".b + data.b + "\nendstream".b)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render(root:, info:)
|
|
34
|
+
raise InvalidDocument, "unassigned PDF object" if @objects.any?(&:nil?)
|
|
35
|
+
|
|
36
|
+
output = +"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
|
|
37
|
+
offsets = [0]
|
|
38
|
+
@objects.each_with_index do |object, index|
|
|
39
|
+
offsets << output.bytesize
|
|
40
|
+
output << "#{index + 1} 0 obj\n".b << object << "\nendobj\n".b
|
|
41
|
+
end
|
|
42
|
+
xref = output.bytesize
|
|
43
|
+
output << "xref\n0 #{@objects.length + 1}\n0000000000 65535 f \n".b
|
|
44
|
+
offsets.drop(1).each { |offset| output << format("%010d 00000 n \n", offset).b }
|
|
45
|
+
output << "trailer\n<< /Size #{@objects.length + 1} /Root #{root} 0 R /Info #{info} 0 R >>\n".b
|
|
46
|
+
output << "startxref\n#{xref}\n%%EOF\n".b
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module Encoding
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
def hex(bytes) = "<#{bytes.b.unpack1('H*')}>"
|
|
54
|
+
|
|
55
|
+
def number(value)
|
|
56
|
+
number = value.to_f
|
|
57
|
+
raise ArgumentError, "PDF numbers must be finite" unless number.finite?
|
|
58
|
+
|
|
59
|
+
BigDecimal(number.to_s).to_s("F")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unicode_hex(text)
|
|
63
|
+
hex(text.encode(::Encoding::UTF_16BE).b)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def name(value)
|
|
67
|
+
value.to_s.b.gsub(/[^A-Za-z0-9_.+-]/) { |byte| format("#%02X", byte.ord) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/okab/version.rb
ADDED
data/lib/okab.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "okab/version"
|
|
4
|
+
require "alhena"
|
|
5
|
+
|
|
6
|
+
module Okab
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class InvalidDocument < Error; end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require_relative "okab/pdf/writer"
|
|
12
|
+
require_relative "okab/font"
|
|
13
|
+
require_relative "okab/image"
|
|
14
|
+
require_relative "okab/page"
|
|
15
|
+
require_relative "okab/document"
|
data/sig/okab.rbs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Okab
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
end
|
|
4
|
+
|
|
5
|
+
class InvalidDocument < Error
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class Document
|
|
9
|
+
def initialize: (?title: String?, ?author: String?, ?creator: String?) -> void
|
|
10
|
+
def page: (width: Numeric, height: Numeric) { (Page) -> void } -> Page
|
|
11
|
+
| (width: Numeric, height: Numeric) -> Page
|
|
12
|
+
def outline: (String title, page: Page, ?level: Integer) -> self
|
|
13
|
+
def owns_page?: (Page page) -> bool
|
|
14
|
+
def render: () -> String
|
|
15
|
+
def write: (String path) -> String
|
|
16
|
+
def embed_font: (Font | Alhena::Font font, ?subset: bool) -> EmbeddedFont
|
|
17
|
+
def register_image: (Image image) -> String
|
|
18
|
+
def opacity_name: (Numeric value) -> String
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Path
|
|
22
|
+
attr_reader commands: Array[String]
|
|
23
|
+
def initialize: () -> void
|
|
24
|
+
def move_to: (Numeric x, Numeric y) -> self
|
|
25
|
+
def line_to: (Numeric x, Numeric y) -> self
|
|
26
|
+
def curve_to: (Numeric x1, Numeric y1, Numeric x2, Numeric y2, Numeric x3, Numeric y3) -> self
|
|
27
|
+
def close: () -> self
|
|
28
|
+
def rect: (Numeric x, Numeric y, Numeric width, Numeric height) -> self
|
|
29
|
+
def ellipse: (Numeric x, Numeric y, Numeric width, Numeric height) -> self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Page
|
|
33
|
+
attr_reader width: Float
|
|
34
|
+
attr_reader height: Float
|
|
35
|
+
attr_reader operations: Array[Array[untyped]]
|
|
36
|
+
attr_reader annotations: Array[Array[untyped]]
|
|
37
|
+
attr_reader document: Document
|
|
38
|
+
def initialize: (Document document, width: Numeric, height: Numeric) -> void
|
|
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 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
|
+
def move_to: (Numeric x, Numeric y) -> self
|
|
42
|
+
def line_to: (Numeric x, Numeric y) -> self
|
|
43
|
+
def curve_to: (Numeric x1, Numeric y1, Numeric x2, Numeric y2, Numeric x3, Numeric y3) -> self
|
|
44
|
+
def close: () -> self
|
|
45
|
+
def rect: (Numeric x, Numeric y, Numeric width, Numeric height) -> self
|
|
46
|
+
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
|
|
49
|
+
def fill_and_stroke: (Array[Numeric] color, ?width: Numeric) -> self
|
|
50
|
+
def clip: () { (Path) -> void } -> self
|
|
51
|
+
def transform: (Numeric a, Numeric b, Numeric c, Numeric d, Numeric e, Numeric f) { (Page) -> void } -> self
|
|
52
|
+
def opacity: (Numeric value) { (Page) -> void } -> self
|
|
53
|
+
def image: (String | Image data, x: Numeric, y: Numeric, width: Numeric, height: Numeric, ?format: Symbol) -> self
|
|
54
|
+
def link: (Array[Numeric] bounds, ?uri: String, ?page: Page) -> self
|
|
55
|
+
def content: (fonts: Hash[Integer, String]) -> String
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Font
|
|
59
|
+
attr_reader face: Alhena::Font
|
|
60
|
+
def self.load: (String path) -> Font
|
|
61
|
+
def self.embed: (Document document, Font | Alhena::Font font, ?subset: bool) -> EmbeddedFont
|
|
62
|
+
def initialize: (Alhena::Font face) -> void
|
|
63
|
+
def measure: (String text, size: Numeric) -> Float
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class EmbeddedFont
|
|
67
|
+
attr_reader document: Document
|
|
68
|
+
attr_reader font: Font
|
|
69
|
+
attr_reader subset: bool
|
|
70
|
+
def initialize: (Document document, Font font, ?subset: bool) -> void
|
|
71
|
+
def face: () -> Alhena::Font
|
|
72
|
+
def measure: (String text, size: Numeric) -> Float
|
|
73
|
+
def encode: (String string) -> String
|
|
74
|
+
def glyph_ids: () -> Array[Integer]
|
|
75
|
+
def subset_font: () -> [String, Alhena::Font, Hash[Integer, Integer], bool]
|
|
76
|
+
def characters: () -> Hash[Integer, [String, Integer]]
|
|
77
|
+
def base_name: (String data) -> String
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class Image
|
|
81
|
+
attr_reader width: Integer
|
|
82
|
+
attr_reader height: Integer
|
|
83
|
+
attr_reader color_space: Symbol
|
|
84
|
+
attr_reader data: String
|
|
85
|
+
attr_reader alpha: String?
|
|
86
|
+
attr_reader filter: Symbol?
|
|
87
|
+
attr_reader decode_parms: String?
|
|
88
|
+
def self.decode: (String bytes, ?format: Symbol) -> Image
|
|
89
|
+
def initialize: (width: Integer, height: Integer, color_space: Symbol, data: String, ?alpha: String?, ?filter: Symbol?, ?decode_parms: String?) -> void
|
|
90
|
+
end
|
|
91
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: okab
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: alhena
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.3.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '0.4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.3.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '0.4'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: bigdecimal
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.1'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.1'
|
|
46
|
+
description: A Ruby library for deterministic PDF generation with embedded TrueType
|
|
47
|
+
fonts, Japanese text extraction, vector graphics, PNG/JPEG images, links, and outlines.
|
|
48
|
+
email:
|
|
49
|
+
- t.yudai92@gmail.com
|
|
50
|
+
executables: []
|
|
51
|
+
extensions: []
|
|
52
|
+
extra_rdoc_files: []
|
|
53
|
+
files:
|
|
54
|
+
- CHANGELOG.md
|
|
55
|
+
- LICENSE.txt
|
|
56
|
+
- README.md
|
|
57
|
+
- Rakefile
|
|
58
|
+
- lib/okab.rb
|
|
59
|
+
- lib/okab/document.rb
|
|
60
|
+
- lib/okab/font.rb
|
|
61
|
+
- lib/okab/image.rb
|
|
62
|
+
- lib/okab/page.rb
|
|
63
|
+
- lib/okab/pdf/writer.rb
|
|
64
|
+
- lib/okab/version.rb
|
|
65
|
+
- sig/okab.rbs
|
|
66
|
+
homepage: https://github.com/noxdea/okab
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
homepage_uri: https://github.com/noxdea/okab
|
|
71
|
+
source_code_uri: https://github.com/noxdea/okab/tree/main
|
|
72
|
+
changelog_uri: https://github.com/noxdea/okab/blob/main/CHANGELOG.md
|
|
73
|
+
rubygems_mfa_required: 'true'
|
|
74
|
+
rdoc_options: []
|
|
75
|
+
require_paths:
|
|
76
|
+
- lib
|
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 3.2.0
|
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0'
|
|
87
|
+
requirements: []
|
|
88
|
+
rubygems_version: 4.0.16
|
|
89
|
+
specification_version: 4
|
|
90
|
+
summary: Generate searchable, font-embedded PDF documents
|
|
91
|
+
test_files: []
|