bandoola 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/.tool-versions +2 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +10 -0
- data/exe/bandoola +5 -0
- data/lib/bandoola/cli.rb +44 -0
- data/lib/bandoola/font.rb +204 -0
- data/lib/bandoola/jpeg.rb +104 -0
- data/lib/bandoola/standard_metrics.rb +113 -0
- data/lib/bandoola/svg.rb +349 -0
- data/lib/bandoola/true_type_font.rb +206 -0
- data/lib/bandoola/true_type_subset.rb +302 -0
- data/lib/bandoola/version.rb +3 -0
- data/lib/bandoola/view/element.rb +431 -0
- data/lib/bandoola/view/file.rb +16 -0
- data/lib/bandoola/view/header.rb +110 -0
- data/lib/bandoola/view/resources.rb +44 -0
- data/lib/bandoola/view/style/borders.rb +66 -0
- data/lib/bandoola/view/style/colors.rb +167 -0
- data/lib/bandoola/view/style/spacing.rb +42 -0
- data/lib/bandoola/view/style/typography.rb +72 -0
- data/lib/bandoola/view/style.rb +139 -0
- data/lib/bandoola/view.rb +364 -0
- data/lib/bandoola.rb +14 -0
- data/sig/bandoola.rbs +4 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 399aa72c7e648f1d22ac8e4c8a3dabd5bec6cb1cbdab69f198c07c678ead2e7e
|
|
4
|
+
data.tar.gz: c2911c8a52cc62d55df0e5ab93c41ac45bcb13647b35ea805640454b37b029d5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9a3601d9583eb626212aea6b50a929c810cf38a40d47072e02b323455227bb0e4ba83dab3d1fcf49252d663563d2990527fce2b8d8a443e9203d4588deadda16
|
|
7
|
+
data.tar.gz: 3da607cdeef5663505cc48f02b1849750da611369c495b5601f3d87d40b3ef460207367b7427cad9c14b6365b903215b43dcaf06966f6452494faa2ffdac2faa
|
data/.tool-versions
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johan Halse
|
|
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,146 @@
|
|
|
1
|
+
# Bandoola
|
|
2
|
+
|
|
3
|
+
Build PDF documents from plain Ruby, styling elements with a Tailwind-inspired
|
|
4
|
+
class vocabulary. No runtime dependencies.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
class Invoice < Bandoola::View
|
|
8
|
+
def view_template
|
|
9
|
+
div(class: "p-8 bg-slate-100 rounded-lg") do
|
|
10
|
+
text(class: "text-3xl font-bold") { "Bandoola" }
|
|
11
|
+
text(class: "text-base text-slate-600 mt-2") { "PDFs, the Tailwind way." }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
invoice = Invoice.new
|
|
17
|
+
invoice.render # build the PDF (also returns the bytes)
|
|
18
|
+
invoice.write("out.pdf")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle add bandoola
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Elements
|
|
28
|
+
|
|
29
|
+
Inside `view_template` you build a tree of elements. Each takes a `class:` (a
|
|
30
|
+
space-separated string or an array of tokens) and, where it makes sense, a block.
|
|
31
|
+
|
|
32
|
+
- `div(class:) { … }` — a block-level box that lays out the elements inside it.
|
|
33
|
+
- `text(class:) { "…" }` — a run of text; the block returns the string. It wraps
|
|
34
|
+
to its box width, and embedded newlines start new lines.
|
|
35
|
+
- `img(src:, class:)` — an image; `src` is a path to an SVG or JPEG.
|
|
36
|
+
|
|
37
|
+
Elements flow like HTML blocks: each fills its container's width and stacks below
|
|
38
|
+
the previous one, unless a width class makes it share a row (and reflow).
|
|
39
|
+
|
|
40
|
+
## Styling
|
|
41
|
+
|
|
42
|
+
The class names follow [Tailwind](https://tailwindcss.com/docs); see their docs
|
|
43
|
+
for the full scales. What Bandoola understands today:
|
|
44
|
+
|
|
45
|
+
| Concern | Classes |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Padding / margin | `p-4`, `px-2`, `pt-1`, `m-4`, `mx-2`, … |
|
|
48
|
+
| Width / height | `w-32`, `w-1/2`, `w-full`, `h-16`, `h-1/2`, `h-full` |
|
|
49
|
+
| Border width | `border-1` … `border-16` |
|
|
50
|
+
| Border radius | `rounded`, `rounded-sm` … `rounded-3xl`, `rounded-full` |
|
|
51
|
+
| Background | `bg-cyan-100`, `bg-slate-800`, `bg-black`, `bg-white` |
|
|
52
|
+
| Border color | `border-blue-600` (needs a border width to show) |
|
|
53
|
+
| Text color | `text-rose-500`, `text-white` |
|
|
54
|
+
| Font family | `font-sans`, `font-serif`, `font-mono`, `font-<registered>` |
|
|
55
|
+
| Font weight | `font-light` … `font-black` |
|
|
56
|
+
| Text size | `text-xs` … `text-9xl` |
|
|
57
|
+
|
|
58
|
+
Sizes and spacing are points (1px ≈ 1pt). Notes:
|
|
59
|
+
|
|
60
|
+
- `div` has no fill or border by default — add `bg-*`, `border-*`, or `rounded-*`.
|
|
61
|
+
- Colors work on the full Tailwind palette plus `black`/`white`; a color with
|
|
62
|
+
no shade (`bg-cyan`) uses 500.
|
|
63
|
+
- A `div`'s `font-*` family and weight are inherited by the text inside it; text
|
|
64
|
+
size is set per `text` element.
|
|
65
|
+
|
|
66
|
+
## Fonts
|
|
67
|
+
|
|
68
|
+
`font-sans`/`serif`/`mono` map to the standard PDF fonts (Helvetica, Times,
|
|
69
|
+
Courier), which cover Western European text. Register a TrueType font for
|
|
70
|
+
anything else:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class Doc < Bandoola::View
|
|
74
|
+
register_font :gowun, "fonts/GowunDodum-Regular.ttf"
|
|
75
|
+
# or per weight: register_font :gowun, regular: "…-Regular.ttf", bold: "…-Bold.ttf"
|
|
76
|
+
|
|
77
|
+
def view_template
|
|
78
|
+
text(class: "font-gowun") { "Räksmörgås · 안녕하세요" }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Embedded fonts render any Unicode they have glyphs for (Identity-H encoding) and
|
|
84
|
+
are subset to just the glyphs you use — a few KB rather than the whole file. Opt
|
|
85
|
+
out with `compact_fonts false` if you'd rather keep the original glyph ids. You
|
|
86
|
+
can register several fonts in one document and use whichever fits each script.
|
|
87
|
+
|
|
88
|
+
## Headers, footers & pages
|
|
89
|
+
|
|
90
|
+
Define `header` and `footer` alongside `view_template` for content that repeats
|
|
91
|
+
at the top and bottom of every page. When the body is taller than one page it
|
|
92
|
+
splits automatically, and `page_number` / `page_count` are available inside the
|
|
93
|
+
header and footer.
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Report < Bandoola::View
|
|
97
|
+
def header = text(class: "font-bold mb-4") { "Quarterly Report" }
|
|
98
|
+
def footer = text(class: "text-sm mt-4") { "Page #{page_number} of #{page_count}" }
|
|
99
|
+
|
|
100
|
+
def view_template
|
|
101
|
+
50.times { |i| div(class: "p-2 mb-2 border-1") { text { "Row #{i + 1}" } } }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Give the header an `mb-*` (and the footer an `mt-*`, or use `my-*`) for some air
|
|
107
|
+
between them and the body. Pages break between top-level elements — an element
|
|
108
|
+
isn't split across a page boundary.
|
|
109
|
+
|
|
110
|
+
## Page size
|
|
111
|
+
|
|
112
|
+
The default page is A4. Override the geometry per view:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class Wide < Bandoola::View
|
|
116
|
+
def width = 1190
|
|
117
|
+
def height = 842
|
|
118
|
+
def margin = 48
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Command line
|
|
123
|
+
|
|
124
|
+
Render a file that defines a view straight to a PDF:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
bundle exec bandoola doc.rb > doc.pdf
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
`bin/setup` to install dependencies, `rake test` to run the tests, `bin/console`
|
|
133
|
+
for a prompt. Running `rake test` also regenerates the inspectable example PDFs
|
|
134
|
+
under `tmp/pdf_samples/`.
|
|
135
|
+
|
|
136
|
+
## Status
|
|
137
|
+
|
|
138
|
+
Bandoola is pre-release and the API may shift. It is **not** a browser — there's
|
|
139
|
+
no cascade, no flexbox, line breaking is greedy (no hyphenation or justification),
|
|
140
|
+
and a class either does what's listed above or is ignored. I add things as I need
|
|
141
|
+
them, so coverage is deliberately partial; PRs and issues that fill a gap are
|
|
142
|
+
welcome.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/exe/bandoola
ADDED
data/lib/bandoola/cli.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
# The `bandoola` command-line tool. It loads a Ruby file that defines a
|
|
3
|
+
# Bandoola::View subclass, renders it, and writes the PDF bytes to an IO
|
|
4
|
+
# (stdout by default), so a document can be produced with:
|
|
5
|
+
#
|
|
6
|
+
# bundle exec bandoola foo.rb > foo.pdf
|
|
7
|
+
#
|
|
8
|
+
# If the file defines more than one view, the last one defined is rendered.
|
|
9
|
+
class CLI
|
|
10
|
+
# Raised for problems the user can fix (bad arguments, missing file, no
|
|
11
|
+
# view); reported as a tidy message rather than a backtrace.
|
|
12
|
+
class Error < Bandoola::Error
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Run the tool. Returns a process exit status (0 on success, 1 on a
|
|
16
|
+
# user-facing error). +out+ receives the PDF; +err+ receives messages.
|
|
17
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
18
|
+
path = argv.first
|
|
19
|
+
raise Error, "usage: bandoola FILE.rb" if path.nil? || path.empty?
|
|
20
|
+
raise Error, "no such file: #{path}" unless File.file?(path)
|
|
21
|
+
|
|
22
|
+
view_class = load_view(path)
|
|
23
|
+
out.binmode if out.respond_to?(:binmode)
|
|
24
|
+
out.write(view_class.new.render)
|
|
25
|
+
0
|
|
26
|
+
rescue Error => e
|
|
27
|
+
err.puts "bandoola: #{e.message}"
|
|
28
|
+
1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Load +path+ and return the View subclass it defined. Comparing the
|
|
32
|
+
# registry before and after isolates the file's own view(s) from any
|
|
33
|
+
# others already loaded.
|
|
34
|
+
def self.load_view(path)
|
|
35
|
+
known = View.registry.dup
|
|
36
|
+
load File.expand_path(path)
|
|
37
|
+
defined_views = View.registry - known
|
|
38
|
+
raise Error, "#{path} defines no Bandoola::View subclass" if defined_views.empty?
|
|
39
|
+
|
|
40
|
+
defined_views.last
|
|
41
|
+
end
|
|
42
|
+
private_class_method :load_view
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
# One of the 14 standard PDF fonts (no embedding needed): viewers ship the
|
|
3
|
+
# metrics, so a single Font object naming the BaseFont is enough. Text is
|
|
4
|
+
# single-byte WinAnsiEncoding, which covers Latin/Western European only.
|
|
5
|
+
class StandardFont
|
|
6
|
+
def initialize(base_font)
|
|
7
|
+
@base_font = base_font
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# How many PDF objects this font occupies (for object numbering).
|
|
11
|
+
def object_count
|
|
12
|
+
1
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The PDF object bodies, given the object number of the first.
|
|
16
|
+
def pdf_objects(_first)
|
|
17
|
+
["<< /Type /Font /Subtype /Type1 /BaseFont /#{@base_font} /Encoding /WinAnsiEncoding >>"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Encode a string as a PDF literal for the Tj operator. Transcode UTF-8 to
|
|
21
|
+
# Windows-1252 (characters outside it become "?") and escape delimiters.
|
|
22
|
+
def encode(string)
|
|
23
|
+
bytes = string.encode("Windows-1252", invalid: :replace, undef: :replace, replace: "?").b
|
|
24
|
+
"(#{bytes.gsub(/[\\()]/) { |char| "\\#{char}" }})"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
COURIER_WIDTH = 600 # Courier is monospaced
|
|
28
|
+
QUESTION_MARK = 0x3F # fallback for characters outside the metric table
|
|
29
|
+
|
|
30
|
+
# The width of +string+ at +size+ points, from the Adobe Core metrics.
|
|
31
|
+
def width_of(string, size)
|
|
32
|
+
string.codepoints.sum { |codepoint| char_width(codepoint) } * size / 1000.0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def char_width(codepoint)
|
|
38
|
+
return COURIER_WIDTH if @base_font.start_with?("Courier")
|
|
39
|
+
|
|
40
|
+
table = METRICS.fetch(@base_font)
|
|
41
|
+
table[codepoint] || table[QUESTION_MARK]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A TrueType font embedded as a Type0 (composite) font with Identity-H
|
|
46
|
+
# encoding. Text is written as the font's own 2-byte glyph ids, which lifts
|
|
47
|
+
# the 256-character limit of simple fonts: any Unicode the font has a glyph
|
|
48
|
+
# for — accented Latin, Cyrillic, CJK — renders. It collects the glyphs used
|
|
49
|
+
# during painting so the /W widths and the /ToUnicode map cover exactly them.
|
|
50
|
+
#
|
|
51
|
+
# It occupies five objects: the Type0 font, the CIDFont descendant, the
|
|
52
|
+
# FontDescriptor, the FontFile2 program, and the ToUnicode CMap.
|
|
53
|
+
class EmbeddedFont
|
|
54
|
+
DEFAULT_WIDTH = 1000
|
|
55
|
+
|
|
56
|
+
CMAP_HEADER = <<~CMAP.freeze
|
|
57
|
+
/CIDInit /ProcSet findresource begin
|
|
58
|
+
12 dict begin
|
|
59
|
+
begincmap
|
|
60
|
+
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
|
61
|
+
/CMapName /Adobe-Identity-UCS def
|
|
62
|
+
/CMapType 2 def
|
|
63
|
+
1 begincodespacerange
|
|
64
|
+
<0000> <FFFF>
|
|
65
|
+
endcodespacerange
|
|
66
|
+
CMAP
|
|
67
|
+
|
|
68
|
+
CMAP_FOOTER = <<~CMAP.freeze
|
|
69
|
+
endcmap
|
|
70
|
+
CMapName currentdict /CMap defineresource pop
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
CMAP
|
|
74
|
+
|
|
75
|
+
def self.load(path, compact: true)
|
|
76
|
+
new(TrueTypeFont.load(path), compact: compact)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def initialize(ttf, compact: true)
|
|
80
|
+
@ttf = ttf
|
|
81
|
+
@compact = compact
|
|
82
|
+
@codes = {} # content-stream code => Unicode code point
|
|
83
|
+
@order = [0] # compact mode: code => original glyph id (0 = .notdef)
|
|
84
|
+
@ids = { 0 => 0 } # compact mode: original glyph id => code
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def object_count
|
|
88
|
+
5
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The width of +string+ at +size+ points, summing the glyphs' advances.
|
|
92
|
+
def width_of(string, size)
|
|
93
|
+
string.codepoints.sum { |codepoint| @ttf.width(codepoint) } * size / 1000.0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Encode a string as Identity-H: each character becomes a 2-byte code,
|
|
97
|
+
# written as a PDF hex string for the Tj operator. In compact mode the code
|
|
98
|
+
# is a freshly assigned dense id; otherwise it's the original glyph id.
|
|
99
|
+
# Records the codes used so /W and /ToUnicode can describe them.
|
|
100
|
+
def encode(string)
|
|
101
|
+
hex = string.each_char.map do |char|
|
|
102
|
+
codepoint = char.ord
|
|
103
|
+
code = code_for(@ttf.glyph(codepoint))
|
|
104
|
+
@codes[code] = codepoint
|
|
105
|
+
format("%04X", code)
|
|
106
|
+
end
|
|
107
|
+
"<#{hex.join}>"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def code_for(glyph)
|
|
111
|
+
return glyph unless @compact
|
|
112
|
+
|
|
113
|
+
@ids[glyph] ||= begin
|
|
114
|
+
@order << glyph
|
|
115
|
+
@order.size - 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def pdf_objects(first)
|
|
120
|
+
descendant = first + 1
|
|
121
|
+
descriptor = first + 2
|
|
122
|
+
file = first + 3
|
|
123
|
+
unicode = first + 4
|
|
124
|
+
[type0_font(descendant, unicode), cid_font(descriptor), font_descriptor(file),
|
|
125
|
+
font_file, to_unicode]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def name
|
|
131
|
+
@ttf.postscript_name
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def type0_font(descendant, unicode)
|
|
135
|
+
"<< /Type /Font /Subtype /Type0 /BaseFont /#{name} /Encoding /Identity-H " \
|
|
136
|
+
"/DescendantFonts [#{descendant} 0 R] /ToUnicode #{unicode} 0 R >>"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def cid_font(descriptor)
|
|
140
|
+
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /#{name} " \
|
|
141
|
+
"/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> " \
|
|
142
|
+
"/FontDescriptor #{descriptor} 0 R /CIDToGIDMap /Identity " \
|
|
143
|
+
"/DW #{DEFAULT_WIDTH} /W [#{widths}] >>"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# One "code [width]" entry per used code (CID equals the content-stream
|
|
147
|
+
# code under the Identity map). In compact mode the code maps back to the
|
|
148
|
+
# original glyph through @order.
|
|
149
|
+
def widths
|
|
150
|
+
@codes.keys.sort.map { |code| "#{code} [#{@ttf.glyph_width(original(code))}]" }.join(" ")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The original glyph id behind a content-stream code.
|
|
154
|
+
def original(code)
|
|
155
|
+
@compact ? @order[code] : code
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def font_descriptor(file)
|
|
159
|
+
bbox = @ttf.font_bbox.join(" ")
|
|
160
|
+
"<< /Type /FontDescriptor /FontName /#{name} /Flags #{@ttf.flags} " \
|
|
161
|
+
"/FontBBox [#{bbox}] /ItalicAngle #{@ttf.italic_angle} /Ascent #{@ttf.ascent} " \
|
|
162
|
+
"/Descent #{@ttf.descent} /CapHeight #{@ttf.cap_height} /StemV 80 " \
|
|
163
|
+
"/FontFile2 #{file} 0 R >>"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The embedded font program: a subset built from exactly the glyphs used,
|
|
167
|
+
# so the file ships a few KB instead of the whole font. Compact mode also
|
|
168
|
+
# renumbers glyphs (shrinking loca/hmtx); sparse mode keeps glyph ids.
|
|
169
|
+
# Falls back to full embedding if the font can't be subset (e.g. CFF).
|
|
170
|
+
# Length1 is the size of the (uncompressed) TrueType data — equal to Length
|
|
171
|
+
# since no filter is applied.
|
|
172
|
+
def font_file
|
|
173
|
+
bytes = subset
|
|
174
|
+
"<< /Length #{bytes.bytesize} /Length1 #{bytes.bytesize} >>\nstream\n".b + bytes + "\nendstream".b
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def subset
|
|
178
|
+
if @compact
|
|
179
|
+
TrueTypeSubset.compact(@ttf.data, @order)
|
|
180
|
+
else
|
|
181
|
+
TrueTypeSubset.sparse(@ttf.data, @codes.keys)
|
|
182
|
+
end
|
|
183
|
+
rescue StandardError
|
|
184
|
+
@ttf.data
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The ToUnicode CMap maps content-stream codes back to Unicode so the text
|
|
188
|
+
# stays searchable and copyable.
|
|
189
|
+
def to_unicode
|
|
190
|
+
body = +CMAP_HEADER
|
|
191
|
+
entries = @codes.map { |code, codepoint| "<#{format("%04X", code)}> <#{utf16(codepoint)}>" }
|
|
192
|
+
entries.each_slice(100) do |chunk|
|
|
193
|
+
body << "#{chunk.size} beginbfchar\n#{chunk.join("\n")}\nendbfchar\n"
|
|
194
|
+
end
|
|
195
|
+
body << CMAP_FOOTER
|
|
196
|
+
"<< /Length #{body.bytesize} >>\nstream\n#{body}endstream"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A code point as UTF-16BE hex (a surrogate pair for non-BMP code points).
|
|
200
|
+
def utf16(codepoint)
|
|
201
|
+
[codepoint].pack("U").encode("UTF-16BE").unpack1("H*").upcase
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
# A JPEG image, embedded into a PDF as an image XObject. PDF can carry JPEG
|
|
3
|
+
# data verbatim through the DCTDecode filter, so the file's bytes go into the
|
|
4
|
+
# stream unchanged; only the pixel dimensions and color space need reading.
|
|
5
|
+
#
|
|
6
|
+
# A standalone value object that knows nothing about View or layout: it
|
|
7
|
+
# supplies its intrinsic size to the layout pass and, at paint time,
|
|
8
|
+
# registers itself with the page Resources and emits the `Do` that draws it.
|
|
9
|
+
class Jpeg
|
|
10
|
+
# SOF (Start Of Frame) markers carry the dimensions. SOF0..SOF15 are
|
|
11
|
+
# 0xC0..0xCF, except DHT (C4), JPG (C8) and DAC (CC), which are not frames.
|
|
12
|
+
NON_FRAME = [0xC4, 0xC8, 0xCC].freeze
|
|
13
|
+
|
|
14
|
+
def self.load(src)
|
|
15
|
+
new(File.binread(src))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(data)
|
|
19
|
+
@data = data
|
|
20
|
+
@width, @height, @components = read_frame(data)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :width, :height
|
|
24
|
+
|
|
25
|
+
# Pixel dimensions double as the intrinsic point size (72 DPI).
|
|
26
|
+
def intrinsic_width
|
|
27
|
+
width
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def aspect
|
|
31
|
+
width.to_f / height
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Append the operators that draw this image into +box+ ([left, top, width,
|
|
35
|
+
# height]). PDF image space is the unit square, so the cm matrix scales it
|
|
36
|
+
# to the box; no y flip is needed (unlike vector paths). Registers the
|
|
37
|
+
# XObject by name.
|
|
38
|
+
def paint_into(buffer, resources, box)
|
|
39
|
+
left, top, width, height = box
|
|
40
|
+
name = resources.add_image(self)
|
|
41
|
+
bottom = top - height
|
|
42
|
+
buffer << "q\n#{fmt(width)} 0 0 #{fmt(height)} #{fmt(left)} #{fmt(bottom)} cm\n" \
|
|
43
|
+
"/#{name} Do\nQ\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The image XObject body (dictionary + raw JPEG stream), as binary bytes.
|
|
47
|
+
def xobject
|
|
48
|
+
header = "<< /Type /XObject /Subtype /Image /Width #{width} /Height #{height} " \
|
|
49
|
+
"/BitsPerComponent 8 /ColorSpace /#{color_space} /Filter /DCTDecode " \
|
|
50
|
+
"/Length #{@data.bytesize} >>\nstream\n"
|
|
51
|
+
header.b + @data + "\nendstream".b
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def color_space
|
|
57
|
+
@components == 1 ? "DeviceGray" : "DeviceRGB"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Walk the JPEG marker segments to the first frame header and read its
|
|
61
|
+
# height, width and component count.
|
|
62
|
+
def read_frame(data)
|
|
63
|
+
offset = 2 # skip the SOI marker (FFD8)
|
|
64
|
+
while offset < data.bytesize - 1
|
|
65
|
+
unless data.getbyte(offset) == 0xFF
|
|
66
|
+
offset += 1
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
marker = data.getbyte(offset + 1)
|
|
71
|
+
offset += 2
|
|
72
|
+
next if standalone?(marker)
|
|
73
|
+
|
|
74
|
+
length = (data.getbyte(offset) << 8) | data.getbyte(offset + 1)
|
|
75
|
+
return frame_size(data, offset) if frame?(marker)
|
|
76
|
+
|
|
77
|
+
offset += length
|
|
78
|
+
end
|
|
79
|
+
raise Error, "could not find JPEG frame header"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Markers that carry no length segment: padding (FF), RSTn/SOI/EOI
|
|
83
|
+
# (D0..D9) and TEM (01).
|
|
84
|
+
def standalone?(marker)
|
|
85
|
+
marker == 0xFF || marker.between?(0xD0, 0xD9) || marker == 0x01
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def frame?(marker)
|
|
89
|
+
marker.between?(0xC0, 0xCF) && !NON_FRAME.include?(marker)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Inside a frame segment: [length:2][precision:1][height:2][width:2][comps:1]
|
|
93
|
+
def frame_size(data, offset)
|
|
94
|
+
height = (data.getbyte(offset + 3) << 8) | data.getbyte(offset + 4)
|
|
95
|
+
width = (data.getbyte(offset + 5) << 8) | data.getbyte(offset + 6)
|
|
96
|
+
components = data.getbyte(offset + 7)
|
|
97
|
+
[width, height, components]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fmt(value)
|
|
101
|
+
format("%.2f", value).sub(/\.?0+\z/, "")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
class StandardFont
|
|
3
|
+
# Adobe Core font advance widths (1000-unit em) by WinAnsi/Latin-1 code
|
|
4
|
+
# point, for the standard PDF faces families map to. Used to measure and
|
|
5
|
+
# wrap text. Courier is monospace, handled in code.
|
|
6
|
+
METRICS = {
|
|
7
|
+
"Helvetica" => {
|
|
8
|
+
32 => 278, 33 => 278, 34 => 355, 35 => 556, 36 => 556, 37 => 889, 38 => 667, 39 => 222,
|
|
9
|
+
40 => 333, 41 => 333, 42 => 389, 43 => 584, 44 => 278, 45 => 333, 46 => 278, 47 => 278,
|
|
10
|
+
48 => 556, 49 => 556, 50 => 556, 51 => 556, 52 => 556, 53 => 556, 54 => 556, 55 => 556,
|
|
11
|
+
56 => 556, 57 => 556, 58 => 278, 59 => 278, 60 => 584, 61 => 584, 62 => 584, 63 => 556,
|
|
12
|
+
64 => 1015, 65 => 667, 66 => 667, 67 => 722, 68 => 722, 69 => 667, 70 => 611, 71 => 778,
|
|
13
|
+
72 => 722, 73 => 278, 74 => 500, 75 => 667, 76 => 556, 77 => 833, 78 => 722, 79 => 778,
|
|
14
|
+
80 => 667, 81 => 778, 82 => 722, 83 => 667, 84 => 611, 85 => 722, 86 => 667, 87 => 944,
|
|
15
|
+
88 => 667, 89 => 667, 90 => 611, 91 => 278, 92 => 278, 93 => 278, 94 => 469, 95 => 556,
|
|
16
|
+
96 => 222, 97 => 556, 98 => 556, 99 => 500, 100 => 556, 101 => 556, 102 => 278, 103 => 556,
|
|
17
|
+
104 => 556, 105 => 222, 106 => 222, 107 => 500, 108 => 222, 109 => 833, 110 => 556, 111 => 556,
|
|
18
|
+
112 => 556, 113 => 556, 114 => 333, 115 => 500, 116 => 278, 117 => 556, 118 => 500, 119 => 722,
|
|
19
|
+
120 => 500, 121 => 500, 122 => 500, 123 => 334, 124 => 260, 125 => 334, 126 => 584, 160 => 278,
|
|
20
|
+
161 => 333, 162 => 556, 163 => 556, 164 => 556, 165 => 556, 166 => 260, 167 => 556, 168 => 333,
|
|
21
|
+
169 => 737, 170 => 370, 171 => 556, 172 => 584, 173 => 333, 174 => 737, 175 => 333, 176 => 400,
|
|
22
|
+
177 => 584, 178 => 333, 179 => 333, 180 => 333, 181 => 556, 182 => 537, 183 => 278, 184 => 333,
|
|
23
|
+
185 => 333, 186 => 365, 187 => 556, 188 => 834, 189 => 834, 190 => 834, 191 => 611, 192 => 667,
|
|
24
|
+
193 => 667, 194 => 667, 195 => 667, 196 => 667, 197 => 667, 198 => 1000, 199 => 722, 200 => 667,
|
|
25
|
+
201 => 667, 202 => 667, 203 => 667, 204 => 278, 205 => 278, 206 => 278, 207 => 278, 208 => 722,
|
|
26
|
+
209 => 722, 210 => 778, 211 => 778, 212 => 778, 213 => 778, 214 => 778, 215 => 584, 216 => 778,
|
|
27
|
+
217 => 722, 218 => 722, 219 => 722, 220 => 722, 221 => 667, 222 => 667, 223 => 611, 224 => 556,
|
|
28
|
+
225 => 556, 226 => 556, 227 => 556, 228 => 556, 229 => 556, 230 => 889, 231 => 500, 232 => 556,
|
|
29
|
+
233 => 556, 234 => 556, 235 => 556, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 556,
|
|
30
|
+
241 => 556, 242 => 556, 243 => 556, 244 => 556, 245 => 556, 246 => 556, 247 => 584, 248 => 611,
|
|
31
|
+
249 => 556, 250 => 556, 251 => 556, 252 => 556, 253 => 500, 254 => 556, 255 => 500
|
|
32
|
+
},
|
|
33
|
+
"Helvetica-Bold" => {
|
|
34
|
+
32 => 278, 33 => 333, 34 => 474, 35 => 556, 36 => 556, 37 => 889, 38 => 722, 39 => 278,
|
|
35
|
+
40 => 333, 41 => 333, 42 => 389, 43 => 584, 44 => 278, 45 => 333, 46 => 278, 47 => 278,
|
|
36
|
+
48 => 556, 49 => 556, 50 => 556, 51 => 556, 52 => 556, 53 => 556, 54 => 556, 55 => 556,
|
|
37
|
+
56 => 556, 57 => 556, 58 => 333, 59 => 333, 60 => 584, 61 => 584, 62 => 584, 63 => 611,
|
|
38
|
+
64 => 975, 65 => 722, 66 => 722, 67 => 722, 68 => 722, 69 => 667, 70 => 611, 71 => 778,
|
|
39
|
+
72 => 722, 73 => 278, 74 => 556, 75 => 722, 76 => 611, 77 => 833, 78 => 722, 79 => 778,
|
|
40
|
+
80 => 667, 81 => 778, 82 => 722, 83 => 667, 84 => 611, 85 => 722, 86 => 667, 87 => 944,
|
|
41
|
+
88 => 667, 89 => 667, 90 => 611, 91 => 333, 92 => 278, 93 => 333, 94 => 584, 95 => 556,
|
|
42
|
+
96 => 278, 97 => 556, 98 => 611, 99 => 556, 100 => 611, 101 => 556, 102 => 333, 103 => 611,
|
|
43
|
+
104 => 611, 105 => 278, 106 => 278, 107 => 556, 108 => 278, 109 => 889, 110 => 611, 111 => 611,
|
|
44
|
+
112 => 611, 113 => 611, 114 => 389, 115 => 556, 116 => 333, 117 => 611, 118 => 556, 119 => 778,
|
|
45
|
+
120 => 556, 121 => 556, 122 => 500, 123 => 389, 124 => 280, 125 => 389, 126 => 584, 160 => 278,
|
|
46
|
+
161 => 333, 162 => 556, 163 => 556, 164 => 556, 165 => 556, 166 => 280, 167 => 556, 168 => 333,
|
|
47
|
+
169 => 737, 170 => 370, 171 => 556, 172 => 584, 173 => 333, 174 => 737, 175 => 333, 176 => 400,
|
|
48
|
+
177 => 584, 178 => 333, 179 => 333, 180 => 333, 181 => 611, 182 => 556, 183 => 278, 184 => 333,
|
|
49
|
+
185 => 333, 186 => 365, 187 => 556, 188 => 834, 189 => 834, 190 => 834, 191 => 611, 192 => 722,
|
|
50
|
+
193 => 722, 194 => 722, 195 => 722, 196 => 722, 197 => 722, 198 => 1000, 199 => 722, 200 => 667,
|
|
51
|
+
201 => 667, 202 => 667, 203 => 667, 204 => 278, 205 => 278, 206 => 278, 207 => 278, 208 => 722,
|
|
52
|
+
209 => 722, 210 => 778, 211 => 778, 212 => 778, 213 => 778, 214 => 778, 215 => 584, 216 => 778,
|
|
53
|
+
217 => 722, 218 => 722, 219 => 722, 220 => 722, 221 => 667, 222 => 667, 223 => 611, 224 => 556,
|
|
54
|
+
225 => 556, 226 => 556, 227 => 556, 228 => 556, 229 => 556, 230 => 889, 231 => 556, 232 => 556,
|
|
55
|
+
233 => 556, 234 => 556, 235 => 556, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 611,
|
|
56
|
+
241 => 611, 242 => 611, 243 => 611, 244 => 611, 245 => 611, 246 => 611, 247 => 584, 248 => 611,
|
|
57
|
+
249 => 611, 250 => 611, 251 => 611, 252 => 611, 253 => 556, 254 => 611, 255 => 556
|
|
58
|
+
},
|
|
59
|
+
"Times-Roman" => {
|
|
60
|
+
32 => 250, 33 => 333, 34 => 408, 35 => 500, 36 => 500, 37 => 833, 38 => 778, 39 => 333,
|
|
61
|
+
40 => 333, 41 => 333, 42 => 500, 43 => 564, 44 => 250, 45 => 333, 46 => 250, 47 => 278,
|
|
62
|
+
48 => 500, 49 => 500, 50 => 500, 51 => 500, 52 => 500, 53 => 500, 54 => 500, 55 => 500,
|
|
63
|
+
56 => 500, 57 => 500, 58 => 278, 59 => 278, 60 => 564, 61 => 564, 62 => 564, 63 => 444,
|
|
64
|
+
64 => 921, 65 => 722, 66 => 667, 67 => 667, 68 => 722, 69 => 611, 70 => 556, 71 => 722,
|
|
65
|
+
72 => 722, 73 => 333, 74 => 389, 75 => 722, 76 => 611, 77 => 889, 78 => 722, 79 => 722,
|
|
66
|
+
80 => 556, 81 => 722, 82 => 667, 83 => 556, 84 => 611, 85 => 722, 86 => 722, 87 => 944,
|
|
67
|
+
88 => 722, 89 => 722, 90 => 611, 91 => 333, 92 => 278, 93 => 333, 94 => 469, 95 => 500,
|
|
68
|
+
96 => 333, 97 => 444, 98 => 500, 99 => 444, 100 => 500, 101 => 444, 102 => 333, 103 => 500,
|
|
69
|
+
104 => 500, 105 => 278, 106 => 278, 107 => 500, 108 => 278, 109 => 778, 110 => 500, 111 => 500,
|
|
70
|
+
112 => 500, 113 => 500, 114 => 333, 115 => 389, 116 => 278, 117 => 500, 118 => 500, 119 => 722,
|
|
71
|
+
120 => 500, 121 => 500, 122 => 444, 123 => 480, 124 => 200, 125 => 480, 126 => 541, 160 => 250,
|
|
72
|
+
161 => 333, 162 => 500, 163 => 500, 164 => 500, 165 => 500, 166 => 200, 167 => 500, 168 => 333,
|
|
73
|
+
169 => 760, 170 => 276, 171 => 500, 172 => 564, 173 => 333, 174 => 760, 175 => 333, 176 => 400,
|
|
74
|
+
177 => 564, 178 => 300, 179 => 300, 180 => 333, 181 => 500, 182 => 453, 183 => 250, 184 => 333,
|
|
75
|
+
185 => 300, 186 => 310, 187 => 500, 188 => 750, 189 => 750, 190 => 750, 191 => 444, 192 => 722,
|
|
76
|
+
193 => 722, 194 => 722, 195 => 722, 196 => 722, 197 => 722, 198 => 889, 199 => 667, 200 => 611,
|
|
77
|
+
201 => 611, 202 => 611, 203 => 611, 204 => 333, 205 => 333, 206 => 333, 207 => 333, 208 => 722,
|
|
78
|
+
209 => 722, 210 => 722, 211 => 722, 212 => 722, 213 => 722, 214 => 722, 215 => 564, 216 => 722,
|
|
79
|
+
217 => 722, 218 => 722, 219 => 722, 220 => 722, 221 => 722, 222 => 556, 223 => 500, 224 => 444,
|
|
80
|
+
225 => 444, 226 => 444, 227 => 444, 228 => 444, 229 => 444, 230 => 667, 231 => 444, 232 => 444,
|
|
81
|
+
233 => 444, 234 => 444, 235 => 444, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 500,
|
|
82
|
+
241 => 500, 242 => 500, 243 => 500, 244 => 500, 245 => 500, 246 => 500, 247 => 564, 248 => 500,
|
|
83
|
+
249 => 500, 250 => 500, 251 => 500, 252 => 500, 253 => 500, 254 => 500, 255 => 500
|
|
84
|
+
},
|
|
85
|
+
"Times-Bold" => {
|
|
86
|
+
32 => 250, 33 => 333, 34 => 555, 35 => 500, 36 => 500, 37 => 1000, 38 => 833, 39 => 333,
|
|
87
|
+
40 => 333, 41 => 333, 42 => 500, 43 => 570, 44 => 250, 45 => 333, 46 => 250, 47 => 278,
|
|
88
|
+
48 => 500, 49 => 500, 50 => 500, 51 => 500, 52 => 500, 53 => 500, 54 => 500, 55 => 500,
|
|
89
|
+
56 => 500, 57 => 500, 58 => 333, 59 => 333, 60 => 570, 61 => 570, 62 => 570, 63 => 500,
|
|
90
|
+
64 => 930, 65 => 722, 66 => 667, 67 => 722, 68 => 722, 69 => 667, 70 => 611, 71 => 778,
|
|
91
|
+
72 => 778, 73 => 389, 74 => 500, 75 => 778, 76 => 667, 77 => 944, 78 => 722, 79 => 778,
|
|
92
|
+
80 => 611, 81 => 778, 82 => 722, 83 => 556, 84 => 667, 85 => 722, 86 => 722, 87 => 1000,
|
|
93
|
+
88 => 722, 89 => 722, 90 => 667, 91 => 333, 92 => 278, 93 => 333, 94 => 581, 95 => 500,
|
|
94
|
+
96 => 333, 97 => 500, 98 => 556, 99 => 444, 100 => 556, 101 => 444, 102 => 333, 103 => 500,
|
|
95
|
+
104 => 556, 105 => 278, 106 => 333, 107 => 556, 108 => 278, 109 => 833, 110 => 556, 111 => 500,
|
|
96
|
+
112 => 556, 113 => 556, 114 => 444, 115 => 389, 116 => 333, 117 => 556, 118 => 500, 119 => 722,
|
|
97
|
+
120 => 500, 121 => 500, 122 => 444, 123 => 394, 124 => 220, 125 => 394, 126 => 520, 160 => 250,
|
|
98
|
+
161 => 333, 162 => 500, 163 => 500, 164 => 500, 165 => 500, 166 => 220, 167 => 500, 168 => 333,
|
|
99
|
+
169 => 747, 170 => 300, 171 => 500, 172 => 570, 173 => 333, 174 => 747, 175 => 333, 176 => 400,
|
|
100
|
+
177 => 570, 178 => 300, 179 => 300, 180 => 333, 181 => 556, 182 => 540, 183 => 250, 184 => 333,
|
|
101
|
+
185 => 300, 186 => 330, 187 => 500, 188 => 750, 189 => 750, 190 => 750, 191 => 500, 192 => 722,
|
|
102
|
+
193 => 722, 194 => 722, 195 => 722, 196 => 722, 197 => 722, 198 => 1000, 199 => 722, 200 => 667,
|
|
103
|
+
201 => 667, 202 => 667, 203 => 667, 204 => 389, 205 => 389, 206 => 389, 207 => 389, 208 => 722,
|
|
104
|
+
209 => 722, 210 => 778, 211 => 778, 212 => 778, 213 => 778, 214 => 778, 215 => 570, 216 => 778,
|
|
105
|
+
217 => 722, 218 => 722, 219 => 722, 220 => 722, 221 => 722, 222 => 611, 223 => 556, 224 => 500,
|
|
106
|
+
225 => 500, 226 => 500, 227 => 500, 228 => 500, 229 => 500, 230 => 722, 231 => 444, 232 => 444,
|
|
107
|
+
233 => 444, 234 => 444, 235 => 444, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 500,
|
|
108
|
+
241 => 556, 242 => 500, 243 => 500, 244 => 500, 245 => 500, 246 => 500, 247 => 570, 248 => 500,
|
|
109
|
+
249 => 556, 250 => 556, 251 => 556, 252 => 556, 253 => 500, 254 => 556, 255 => 500
|
|
110
|
+
}
|
|
111
|
+
}.freeze
|
|
112
|
+
end
|
|
113
|
+
end
|