stationery 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 +94 -1
- data/README.md +358 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +25 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +8 -5
- data/lib/stationery/canvas.rb +25 -8
- data/lib/stationery/cli/fonts.rb +85 -0
- data/lib/stationery/cli/render.rb +137 -0
- data/lib/stationery/cli.rb +74 -0
- data/lib/stationery/document.rb +53 -9
- data/lib/stationery/elements/lists.rb +58 -0
- data/lib/stationery/elements/rich.rb +27 -0
- data/lib/stationery/elements.rb +88 -12
- data/lib/stationery/errors.rb +13 -1
- data/lib/stationery/fonts/bundled.rb +25 -0
- data/lib/stationery/fonts/catalog.rb +101 -0
- data/lib/stationery/fonts/cff.rb +140 -0
- data/lib/stationery/fonts/cff_subset.rb +145 -0
- data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
- data/lib/stationery/fonts/data/OFL.txt +92 -0
- data/lib/stationery/fonts/data/README.md +14 -0
- data/lib/stationery/fonts/embedding/base.rb +56 -0
- data/lib/stationery/fonts/embedding/cff.rb +35 -0
- data/lib/stationery/fonts/embedding/true_type.rb +35 -0
- data/lib/stationery/fonts/fallback.rb +58 -0
- data/lib/stationery/fonts/family.rb +11 -0
- data/lib/stationery/fonts/font.rb +52 -88
- data/lib/stationery/fonts/font_book.rb +46 -8
- data/lib/stationery/fonts/glyph_run.rb +38 -0
- data/lib/stationery/fonts/gpos/class_def.rb +25 -0
- data/lib/stationery/fonts/gpos/coverage.rb +20 -0
- data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
- data/lib/stationery/fonts/gpos.rb +62 -0
- data/lib/stationery/fonts/installer.rb +121 -0
- data/lib/stationery/fonts/kern_table.rb +52 -0
- data/lib/stationery/fonts/kerning.rb +17 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
- data/lib/stationery/fonts/to_unicode.rb +36 -0
- data/lib/stationery/fonts/true_type.rb +32 -49
- data/lib/stationery/html/document.rb +11 -0
- data/lib/stationery/html/tokenizer.rb +74 -0
- data/lib/stationery/html/tree_builder/blocks.rb +137 -0
- data/lib/stationery/html/tree_builder/collector.rb +50 -0
- data/lib/stationery/html/tree_builder.rb +72 -0
- data/lib/stationery/html/whitespace.rb +43 -0
- data/lib/stationery/layout/box.rb +85 -16
- data/lib/stationery/layout/flow.rb +48 -15
- data/lib/stationery/layout/image.rb +1 -0
- data/lib/stationery/layout/list_item.rb +89 -0
- data/lib/stationery/layout/mark.rb +58 -0
- data/lib/stationery/layout/node.rb +26 -2
- data/lib/stationery/layout/paginator.rb +35 -6
- data/lib/stationery/layout/positioned.rb +3 -1
- data/lib/stationery/layout/row.rb +34 -4
- data/lib/stationery/layout/svg.rb +45 -0
- data/lib/stationery/layout/table/cell.rb +33 -6
- data/lib/stationery/layout/table/grid.rb +93 -0
- data/lib/stationery/layout/table/row_splitter.rb +36 -0
- data/lib/stationery/layout/table/selection.rb +3 -2
- data/lib/stationery/layout/table.rb +69 -23
- data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
- data/lib/stationery/layout/table_of_contents.rb +43 -0
- data/lib/stationery/layout/text.rb +5 -4
- data/lib/stationery/layout/wrap.rb +78 -0
- data/lib/stationery/list_markers.rb +47 -0
- data/lib/stationery/markdown/block_parser/containers.rb +94 -0
- data/lib/stationery/markdown/block_parser/tables.rb +54 -0
- data/lib/stationery/markdown/block_parser.rb +141 -0
- data/lib/stationery/markdown/document.rb +50 -0
- data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
- data/lib/stationery/markdown/inline_parser/links.rb +70 -0
- data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
- data/lib/stationery/markdown/inline_parser.rb +89 -0
- data/lib/stationery/minitest.rb +33 -0
- data/lib/stationery/outline.rb +37 -0
- data/lib/stationery/page.rb +18 -4
- data/lib/stationery/page_templates.rb +33 -10
- data/lib/stationery/pdf/assembler.rb +18 -7
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +5 -1
- data/lib/stationery/pdf/writer.rb +4 -0
- data/lib/stationery/preview.rb +64 -0
- data/lib/stationery/rails/previews_controller.rb +61 -0
- data/lib/stationery/rails.rb +2 -0
- data/lib/stationery/railtie.rb +60 -0
- data/lib/stationery/regions.rb +67 -0
- data/lib/stationery/rich/nodes.rb +55 -0
- data/lib/stationery/rich/renderer/inlines.rb +53 -0
- data/lib/stationery/rich/renderer.rb +121 -0
- data/lib/stationery/rich/styles.rb +34 -0
- data/lib/stationery/rspec.rb +6 -0
- data/lib/stationery/structure.rb +78 -0
- data/lib/stationery/svg/arc.rb +91 -0
- data/lib/stationery/svg/document.rb +73 -0
- data/lib/stationery/svg/parser.rb +38 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +68 -0
- data/lib/stationery/svg/transform.rb +43 -0
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +123 -0
- data/lib/stationery/text/entities/html4.rb +49 -0
- data/lib/stationery/text/entities.rb +6 -1
- data/lib/stationery/text/line.rb +8 -2
- data/lib/stationery/text/paragraph.rb +32 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +2 -2
- data/lib/stationery/text/wrapper.rb +3 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +51 -0
- metadata +85 -4
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gpos
|
|
6
|
+
# OpenType Coverage table: the glyphs a subtable applies to, in
|
|
7
|
+
# coverage-index order. Format 1 lists glyphs, format 2 ranges.
|
|
8
|
+
module Coverage
|
|
9
|
+
def self.read(ttf, offset)
|
|
10
|
+
count = ttf.u16(offset + 2)
|
|
11
|
+
return ttf.data.byteslice(offset + 4, count * 2).unpack("n*") if ttf.u16(offset) == 1
|
|
12
|
+
|
|
13
|
+
ttf.data.byteslice(offset + 4, count * 6).unpack("n*").each_slice(3).flat_map do |first, last, _index|
|
|
14
|
+
(first..last).to_a
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gpos
|
|
6
|
+
# PairPos subtables (GPOS lookup type 2), keeping only the first glyph's
|
|
7
|
+
# X advance. `adjust` answers nil when the subtable does not apply, so
|
|
8
|
+
# the lookup tries its next subtable.
|
|
9
|
+
module PairPos
|
|
10
|
+
X_ADVANCE = 0x0004
|
|
11
|
+
|
|
12
|
+
def self.read(ttf, offset)
|
|
13
|
+
ttf.u16(offset) == 1 ? Pairs.read(ttf, offset) : Classes.read(ttf, offset)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The first glyph's X advance of `count` records at `offset`, each
|
|
17
|
+
# `lead` 16-bit fields (a second glyph id) followed by two value
|
|
18
|
+
# records. A value record has one field per bit set in its format.
|
|
19
|
+
def self.x_advances(ttf, offset, count, format1, format2, lead: 0)
|
|
20
|
+
width = [lead + fields(format1) + fields(format2), 1].max
|
|
21
|
+
records = ttf.data.byteslice(offset, count * width * 2).unpack("s>*").each_slice(width)
|
|
22
|
+
return records.map { |record| [record.first & 0xFFFF, 0] } unless format1.anybits?(X_ADVANCE)
|
|
23
|
+
|
|
24
|
+
index = lead + fields(format1 & (X_ADVANCE - 1))
|
|
25
|
+
records.map { |record| [record.first & 0xFFFF, record[index]] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.fields(format) = format.to_s(2).count("1")
|
|
29
|
+
|
|
30
|
+
# Format 1: explicit pairs, grouped by first glyph.
|
|
31
|
+
class Pairs
|
|
32
|
+
def self.read(ttf, offset)
|
|
33
|
+
format1 = ttf.u16(offset + 4)
|
|
34
|
+
format2 = ttf.u16(offset + 6)
|
|
35
|
+
pairs = {}
|
|
36
|
+
Coverage.read(ttf, offset + ttf.u16(offset + 2)).each_with_index do |left, i|
|
|
37
|
+
set = offset + ttf.u16(offset + 10 + (i * 2))
|
|
38
|
+
PairPos.x_advances(ttf, set + 2, ttf.u16(set), format1, format2, lead: 1).each do |right, value|
|
|
39
|
+
pairs[(left << 16) | right] = value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
new(pairs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(pairs)
|
|
46
|
+
@pairs = pairs.freeze
|
|
47
|
+
freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def adjust(left, right) = @pairs[(left << 16) | right]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Format 2: a class-by-class matrix over two ClassDefs, kept as classes
|
|
54
|
+
# rather than expanded into every glyph pair.
|
|
55
|
+
class Classes
|
|
56
|
+
def self.read(ttf, offset)
|
|
57
|
+
format1 = ttf.u16(offset + 4)
|
|
58
|
+
format2 = ttf.u16(offset + 6)
|
|
59
|
+
columns = ttf.u16(offset + 14)
|
|
60
|
+
values = PairPos.x_advances(ttf, offset + 16, ttf.u16(offset + 12) * columns, format1, format2)
|
|
61
|
+
new(Coverage.read(ttf, offset + ttf.u16(offset + 2)),
|
|
62
|
+
ClassDef.read(ttf, offset + ttf.u16(offset + 8)), ClassDef.read(ttf, offset + ttf.u16(offset + 10)),
|
|
63
|
+
values.map(&:last).each_slice(columns).to_a)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(coverage, first_classes, second_classes, matrix)
|
|
67
|
+
@coverage = coverage.to_h { |gid| [gid, true] }.freeze
|
|
68
|
+
@first_classes = first_classes
|
|
69
|
+
@second_classes = second_classes
|
|
70
|
+
@matrix = matrix.each(&:freeze).freeze
|
|
71
|
+
freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def adjust(left, right)
|
|
75
|
+
return unless @coverage[left]
|
|
76
|
+
|
|
77
|
+
@matrix.dig(@first_classes.fetch(left, 0), @second_classes.fetch(right, 0)) || 0
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Pair kerning from the GPOS `kern` feature: PairPos lookups (formats 1
|
|
6
|
+
# and 2), directly or behind Extension lookups. Scripts and languages are
|
|
7
|
+
# not distinguished; every lookup any `kern` feature record names applies.
|
|
8
|
+
# Adjustments are the first glyph's X advance, in font units.
|
|
9
|
+
class Gpos
|
|
10
|
+
PAIR = 2
|
|
11
|
+
EXTENSION = 9
|
|
12
|
+
|
|
13
|
+
# nil when the font has no GPOS table or no `kern` feature.
|
|
14
|
+
def self.parse(ttf)
|
|
15
|
+
base = ttf.table_offset("GPOS")
|
|
16
|
+
return unless base && ttf.u16(base) == 1
|
|
17
|
+
|
|
18
|
+
indices = kern_lookups(ttf, base + ttf.u16(base + 6))
|
|
19
|
+
return if indices.empty?
|
|
20
|
+
|
|
21
|
+
list = base + ttf.u16(base + 8)
|
|
22
|
+
new(indices.map { |i| lookup(ttf, list + ttf.u16(list + 2 + (i * 2))) })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.kern_lookups(ttf, list)
|
|
26
|
+
records = Array.new(ttf.u16(list)) { |i| list + 2 + (i * 6) }
|
|
27
|
+
records.select { |record| ttf.data.byteslice(record, 4) == "kern" }.flat_map do |record|
|
|
28
|
+
feature = list + ttf.u16(record + 4)
|
|
29
|
+
Array.new(ttf.u16(feature + 2)) { |j| ttf.u16(feature + 4 + (j * 2)) }
|
|
30
|
+
end.uniq
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A lookup's PairPos subtables; other lookup types have none.
|
|
34
|
+
def self.lookup(ttf, offset)
|
|
35
|
+
type = ttf.u16(offset)
|
|
36
|
+
subtables = Array.new(ttf.u16(offset + 4)) { |i| offset + ttf.u16(offset + 6 + (i * 2)) }
|
|
37
|
+
subtables.filter_map do |subtable|
|
|
38
|
+
if type == PAIR
|
|
39
|
+
PairPos.read(ttf, subtable)
|
|
40
|
+
elsif type == EXTENSION && ttf.u16(subtable + 2) == PAIR
|
|
41
|
+
PairPos.read(ttf, subtable + ttf.u32(subtable + 4))
|
|
42
|
+
end
|
|
43
|
+
end.freeze
|
|
44
|
+
end
|
|
45
|
+
private_class_method :kern_lookups, :lookup
|
|
46
|
+
|
|
47
|
+
def initialize(lookups)
|
|
48
|
+
@lookups = lookups.freeze
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Within a lookup the first subtable that applies wins; lookups add up.
|
|
53
|
+
def adjust(left, right)
|
|
54
|
+
@lookups.sum do |subtables|
|
|
55
|
+
value = nil
|
|
56
|
+
subtables.find { |subtable| value = subtable.adjust(left, right) }
|
|
57
|
+
value || 0
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Copies a catalog pack into `<into>/<pack>/`: every file is fetched (or
|
|
6
|
+
# read from `from:`, a directory or .tar.gz, for offline installs) and its
|
|
7
|
+
# SHA-256 checked before anything is written, then each file is written to
|
|
8
|
+
# a temporary name and renamed into place. Existing files are kept unless
|
|
9
|
+
# `force:`. Development-time only; documents never download fonts.
|
|
10
|
+
class Installer
|
|
11
|
+
# Fetches an https URL, following up to five redirects.
|
|
12
|
+
HTTP = lambda do |url, redirects = 5|
|
|
13
|
+
require "net/http"
|
|
14
|
+
uri = URI(url)
|
|
15
|
+
raise Error, "refusing to download over #{uri.scheme}: #{url} (https only)" unless uri.scheme == "https"
|
|
16
|
+
|
|
17
|
+
response = Net::HTTP.get_response(uri)
|
|
18
|
+
case response
|
|
19
|
+
when Net::HTTPSuccess then response.body.b
|
|
20
|
+
when Net::HTTPRedirection
|
|
21
|
+
raise Error, "too many redirects fetching #{url}" if redirects.zero?
|
|
22
|
+
|
|
23
|
+
HTTP.call(uri.merge(response["location"]).to_s, redirects - 1)
|
|
24
|
+
else raise Error, "HTTP #{response.code} fetching #{url}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :into
|
|
29
|
+
|
|
30
|
+
def initialize(into: DEFAULT_DIR, force: false, from: nil, fetcher: HTTP, out: nil)
|
|
31
|
+
@into = into
|
|
32
|
+
@force = force
|
|
33
|
+
@from = from
|
|
34
|
+
@fetcher = fetcher
|
|
35
|
+
@out = out
|
|
36
|
+
@archives = {}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Installs one pack; returns { style => path }.
|
|
40
|
+
def install(key)
|
|
41
|
+
pack = Catalog.fetch(key)
|
|
42
|
+
dir = File.join(@into, pack.key.to_s)
|
|
43
|
+
entries = [*pack.files.values, pack.license_file]
|
|
44
|
+
pending = entries.filter_map { |entry| [entry, File.join(dir, entry.last)] unless keep?(entry.last, dir) }
|
|
45
|
+
verified = pending.map { |(url, sha, name), target| [target, verify(name, sha, read(url, name))] }
|
|
46
|
+
verified.each { |target, bytes| write(target, bytes) }
|
|
47
|
+
pack.files.transform_values { |(*, name)| File.join(dir, name) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def keep?(name, dir)
|
|
53
|
+
target = File.join(dir, name)
|
|
54
|
+
return false if @force || !File.exist?(target)
|
|
55
|
+
|
|
56
|
+
say "exists #{target} (--force replaces it)"
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read(url, name)
|
|
61
|
+
source = source_name(url, name)
|
|
62
|
+
return offline(source) if @from
|
|
63
|
+
return File.binread(File.join(Bundled::DIR, source)) unless url
|
|
64
|
+
|
|
65
|
+
location, member = url.split("#", 2)
|
|
66
|
+
bytes = @archives[location] ||= fetch(location)
|
|
67
|
+
member ? untar(bytes, location, member) { it == member } : bytes
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def source_name(url, name) = url ? File.basename(url.split("#").last) : name
|
|
71
|
+
|
|
72
|
+
def fetch(url)
|
|
73
|
+
say "fetch #{url}"
|
|
74
|
+
@fetcher.call(url)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def offline(source)
|
|
78
|
+
if File.directory?(@from)
|
|
79
|
+
path = Dir.glob("**/#{source}", base: @from).min or raise Error, "#{source} not found in #{@from}"
|
|
80
|
+
File.binread(File.join(@from, path))
|
|
81
|
+
else
|
|
82
|
+
@archives[@from] ||= File.binread(@from)
|
|
83
|
+
untar(@archives[@from], @from, source) { File.basename(it) == source }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The first file in a .tar.gz whose member name the block accepts.
|
|
88
|
+
def untar(bytes, label, wanted)
|
|
89
|
+
require "rubygems/package"
|
|
90
|
+
require "stringio"
|
|
91
|
+
require "zlib"
|
|
92
|
+
tar = StringIO.new(Zlib::GzipReader.new(StringIO.new(bytes)).read)
|
|
93
|
+
Gem::Package::TarReader.new(tar).each do |entry|
|
|
94
|
+
return entry.read.b if entry.file? && yield(entry.full_name)
|
|
95
|
+
end
|
|
96
|
+
raise Error, "#{wanted} not found in #{label}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def verify(name, sha, bytes)
|
|
100
|
+
require "digest"
|
|
101
|
+
actual = Digest::SHA256.hexdigest(bytes)
|
|
102
|
+
return bytes if actual == sha
|
|
103
|
+
|
|
104
|
+
raise Error, "SHA-256 mismatch for #{name}: expected #{sha}, got #{actual}; nothing written"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def write(target, bytes)
|
|
108
|
+
require "fileutils"
|
|
109
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
110
|
+
temp = "#{target}.#{Process.pid}.tmp"
|
|
111
|
+
File.binwrite(temp, bytes)
|
|
112
|
+
File.rename(temp, target)
|
|
113
|
+
say "wrote #{target}"
|
|
114
|
+
ensure
|
|
115
|
+
FileUtils.rm_f(temp) if temp
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def say(line) = @out&.puts(line)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Pair kerning from a TrueType `kern` table: the OpenType (version 0)
|
|
6
|
+
# layout with horizontal format 0 subtables. Apple's version 1 table,
|
|
7
|
+
# vertical, cross-stream and class-based subtables are ignored.
|
|
8
|
+
class KernTable
|
|
9
|
+
HORIZONTAL = 0x0001
|
|
10
|
+
CROSS_STREAM = 0x0004
|
|
11
|
+
OVERRIDE = 0x0008
|
|
12
|
+
SUBTABLE_HEADER = 6
|
|
13
|
+
PAIR = 6
|
|
14
|
+
|
|
15
|
+
# nil when the font has no usable kern table.
|
|
16
|
+
def self.parse(ttf)
|
|
17
|
+
offset = ttf.table_offset("kern")
|
|
18
|
+
return unless offset && ttf.u16(offset).zero?
|
|
19
|
+
|
|
20
|
+
pairs = {}
|
|
21
|
+
cursor = offset + 4
|
|
22
|
+
ttf.u16(offset + 2).times { cursor = read_subtable(ttf, cursor, pairs) }
|
|
23
|
+
new(pairs)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the offset of the next subtable. A format 0 subtable's length
|
|
27
|
+
# is derived from its pair count: large tables overflow the 16-bit field.
|
|
28
|
+
def self.read_subtable(ttf, offset, pairs)
|
|
29
|
+
coverage = ttf.u16(offset + 4)
|
|
30
|
+
return offset + ttf.u16(offset + 2) unless (coverage >> 8).zero?
|
|
31
|
+
|
|
32
|
+
count = ttf.u16(offset + SUBTABLE_HEADER)
|
|
33
|
+
start = offset + SUBTABLE_HEADER + 8
|
|
34
|
+
if coverage.anybits?(HORIZONTAL) && coverage.nobits?(CROSS_STREAM)
|
|
35
|
+
ttf.data.byteslice(start, count * PAIR).unpack("nns>" * count).each_slice(3) do |left, right, value|
|
|
36
|
+
key = (left << 16) | right
|
|
37
|
+
pairs[key] = coverage.nobits?(OVERRIDE) ? pairs.fetch(key, 0) + value : value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
start + (count * PAIR)
|
|
41
|
+
end
|
|
42
|
+
private_class_method :read_subtable
|
|
43
|
+
|
|
44
|
+
def initialize(pairs)
|
|
45
|
+
@pairs = pairs.freeze
|
|
46
|
+
freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def adjust(left, right) = @pairs.fetch((left << 16) | right, 0)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Chooses where a font's pair kerning comes from. Every source answers
|
|
6
|
+
# `adjust(left_gid, right_gid)` with an extra advance in font units.
|
|
7
|
+
module Kerning
|
|
8
|
+
# For fonts without kerning data.
|
|
9
|
+
module NONE
|
|
10
|
+
def self.adjust(_left, _right) = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# A GPOS `kern` feature takes precedence over the legacy `kern` table.
|
|
14
|
+
def self.for(ttf) = Gpos.parse(ttf) || KernTable.parse(ttf) || NONE
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Font packs: `Fonts.install(:noto_sans)` copies a catalog pack into
|
|
5
|
+
# vendor/fonts/noto_sans/, after which `font_family "Noto Sans"` finds it
|
|
6
|
+
# through `Stationery.font_paths` (the Railtie adds vendor/fonts).
|
|
7
|
+
module Fonts
|
|
8
|
+
DEFAULT_DIR = "vendor/fonts"
|
|
9
|
+
|
|
10
|
+
def self.catalog = Catalog::PACKS
|
|
11
|
+
|
|
12
|
+
def self.install(key, into: DEFAULT_DIR, **) = Installer.new(into:, **).install(key)
|
|
13
|
+
|
|
14
|
+
# { style => path } for a pack installed under dir.
|
|
15
|
+
def self.paths(key, dir: DEFAULT_DIR)
|
|
16
|
+
pack = Catalog.fetch(key)
|
|
17
|
+
pack.files.transform_values { |(*, file)| File.join(dir, pack.key.to_s, file) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Ruby lines selecting an installed pack.
|
|
21
|
+
def self.usage(key, dir)
|
|
22
|
+
pack = Catalog.fetch(key)
|
|
23
|
+
short = "font_family #{pack.family.inspect}"
|
|
24
|
+
return [short] if Stationery.font_paths.include?(File.expand_path(dir))
|
|
25
|
+
|
|
26
|
+
["#{short} # when #{dir} is in Stationery.font_paths (Rails adds vendor/fonts)",
|
|
27
|
+
"#{short}, **Stationery::Fonts.paths(:#{pack.key}, dir: #{dir.inspect})"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Installed packs found by family name in Stationery.font_paths.
|
|
31
|
+
module Packs
|
|
32
|
+
def self.family(name)
|
|
33
|
+
Catalog::PACKS.each do |pack|
|
|
34
|
+
next unless pack.family.casecmp?(name.to_s)
|
|
35
|
+
|
|
36
|
+
Stationery.font_paths.each do |dir|
|
|
37
|
+
paths = Fonts.paths(pack.key, dir:).select { |_, path| File.file?(path) }
|
|
38
|
+
return Family.new(pack.family, **paths) if paths[:regular]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# The hhea, post and OS/2 metrics of an sfnt font, with fallbacks for the
|
|
6
|
+
# optional tables. Included by TrueType.
|
|
7
|
+
module SfntMetrics
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def parse_hhea
|
|
11
|
+
t = table_offset("hhea")
|
|
12
|
+
@ascender = i16(t + 4)
|
|
13
|
+
@descender = i16(t + 6)
|
|
14
|
+
@line_gap = i16(t + 8)
|
|
15
|
+
@number_of_hmetrics = u16(t + 34)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse_post
|
|
19
|
+
if (t = table_offset("post"))
|
|
20
|
+
@italic_angle = i16(t + 4) + (u16(t + 6) / 65_536.0)
|
|
21
|
+
@underline_position = i16(t + 8)
|
|
22
|
+
@underline_thickness = i16(t + 10)
|
|
23
|
+
@fixed_pitch = u32(t + 12) != 0
|
|
24
|
+
else
|
|
25
|
+
@italic_angle = 0
|
|
26
|
+
@underline_position = -@units_per_em / 10
|
|
27
|
+
@underline_thickness = @units_per_em / 20
|
|
28
|
+
@fixed_pitch = false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse_os2
|
|
33
|
+
if (t = table_offset("OS/2"))
|
|
34
|
+
@weight = u16(t + 4)
|
|
35
|
+
@strikeout_size = i16(t + 26)
|
|
36
|
+
@strikeout_position = i16(t + 28)
|
|
37
|
+
if u16(t) >= 2
|
|
38
|
+
@x_height = i16(t + 86)
|
|
39
|
+
@cap_height = i16(t + 88)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@weight ||= 400
|
|
44
|
+
@strikeout_size ||= @underline_thickness
|
|
45
|
+
@strikeout_position ||= (@ascender * 0.25).round
|
|
46
|
+
@x_height ||= (@ascender * 0.5).round
|
|
47
|
+
@cap_height = (@ascender * 0.7).round if @cap_height.nil?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# The ToUnicode CMap mapping each two-byte character code a document drew
|
|
6
|
+
# back to its text, so the PDF extracts and searches correctly.
|
|
7
|
+
module ToUnicode
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# `chars` is { code => character }.
|
|
11
|
+
def cmap(chars)
|
|
12
|
+
mappings = chars.sort.map do |code, char|
|
|
13
|
+
format("<%<code>04X> <%<utf16>s>", code:, utf16: char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)
|
|
14
|
+
end
|
|
15
|
+
blocks = mappings.each_slice(100).map { |slice| "#{slice.size} beginbfchar\n#{slice.join("\n")}\nendbfchar" }
|
|
16
|
+
|
|
17
|
+
<<~CMAP
|
|
18
|
+
/CIDInit /ProcSet findresource begin
|
|
19
|
+
12 dict begin
|
|
20
|
+
begincmap
|
|
21
|
+
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
|
22
|
+
/CMapName /Adobe-Identity-UCS def
|
|
23
|
+
/CMapType 2 def
|
|
24
|
+
1 begincodespacerange
|
|
25
|
+
<0000> <FFFF>
|
|
26
|
+
endcodespacerange
|
|
27
|
+
#{blocks.join("\n")}
|
|
28
|
+
endcmap
|
|
29
|
+
CMapName currentdict /CMap defineresource pop
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
CMAP
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
|
-
# Reads the tables of a
|
|
6
|
-
# embed
|
|
7
|
-
# collections and web font wrappers are rejected with a named
|
|
5
|
+
# Reads the sfnt tables of a static font needed to measure text and to
|
|
6
|
+
# embed it: TrueType outlines (.ttf) or CFF outlines (.otf). Variable CFF2
|
|
7
|
+
# fonts, font collections and web font wrappers are rejected with a named
|
|
8
|
+
# reason.
|
|
8
9
|
class TrueType
|
|
9
|
-
|
|
10
|
+
include SfntMetrics
|
|
11
|
+
|
|
12
|
+
REQUIRED_TABLES = %w[head hhea maxp hmtx].freeze
|
|
13
|
+
OUTLINE_TABLES = { "OTTO" => ["CFF "] }.freeze
|
|
14
|
+
GLYF_TABLES = %w[loca glyf].freeze
|
|
15
|
+
SFNT_VERSIONS = ["\x00\x01\x00\x00".b, "true", "OTTO"].freeze
|
|
10
16
|
SIGNATURES = {
|
|
11
|
-
"OTTO" => "OpenType fonts with CFF outlines (.otf) are not supported, use a TrueType (.ttf) font",
|
|
12
17
|
"ttcf" => "TrueType collections (.ttc) are not supported, use a single TrueType (.ttf) font",
|
|
13
18
|
"wOFF" => "WOFF web fonts are not supported, use the TrueType (.ttf) file",
|
|
14
19
|
"wOF2" => "WOFF2 web fonts are not supported, use the TrueType (.ttf) file"
|
|
@@ -28,7 +33,7 @@ module Stationery
|
|
|
28
33
|
parse_hhea
|
|
29
34
|
parse_maxp
|
|
30
35
|
parse_hmtx
|
|
31
|
-
parse_loca
|
|
36
|
+
parse_loca unless cff?
|
|
32
37
|
@cmap = cmap ? Cmap.parse(self) : {}
|
|
33
38
|
parse_post
|
|
34
39
|
parse_os2
|
|
@@ -57,6 +62,21 @@ module Stationery
|
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
|
|
65
|
+
# Pair kerning, read on first use. Shared by every document using this
|
|
66
|
+
# parse; the source is immutable once built.
|
|
67
|
+
def kerning
|
|
68
|
+
@kerning ||= Kerning.for(self)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cff?
|
|
72
|
+
@tables.key?("CFF ")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The CFF table, read on first use; nil for TrueType outlines.
|
|
76
|
+
def cff
|
|
77
|
+
@cff ||= CFF.new(table_data("CFF ")) if cff?
|
|
78
|
+
end
|
|
79
|
+
|
|
60
80
|
def fixed_pitch?
|
|
61
81
|
@fixed_pitch
|
|
62
82
|
end
|
|
@@ -88,7 +108,7 @@ module Stationery
|
|
|
88
108
|
|
|
89
109
|
def check_signature
|
|
90
110
|
signature = @data.byteslice(0, 4)
|
|
91
|
-
return if
|
|
111
|
+
return if SFNT_VERSIONS.include?(signature)
|
|
92
112
|
|
|
93
113
|
raise UnsupportedFont, SIGNATURES.fetch(signature, "not a TrueType font")
|
|
94
114
|
end
|
|
@@ -99,9 +119,12 @@ module Stationery
|
|
|
99
119
|
record = 12 + (i * 16)
|
|
100
120
|
@tables[@data.byteslice(record, 4)] = [u32(record + 8), u32(record + 12)]
|
|
101
121
|
end
|
|
102
|
-
|
|
122
|
+
raise UnsupportedFont, "variable CFF2 fonts are not supported" if @tables.key?("CFF2")
|
|
123
|
+
|
|
124
|
+
required = REQUIRED_TABLES + OUTLINE_TABLES.fetch(@data.byteslice(0, 4), GLYF_TABLES)
|
|
125
|
+
required += ["cmap"] if cmap
|
|
103
126
|
missing = required.reject { |tag| @tables.key?(tag) }
|
|
104
|
-
raise UnsupportedFont, "font is missing the #{missing.join(", ")} table" if missing.any?
|
|
127
|
+
raise UnsupportedFont, "font is missing the #{missing.map(&:strip).join(", ")} table" if missing.any?
|
|
105
128
|
end
|
|
106
129
|
|
|
107
130
|
def parse_head
|
|
@@ -111,14 +134,6 @@ module Stationery
|
|
|
111
134
|
@index_to_loc_format = i16(t + 50)
|
|
112
135
|
end
|
|
113
136
|
|
|
114
|
-
def parse_hhea
|
|
115
|
-
t = table_offset("hhea")
|
|
116
|
-
@ascender = i16(t + 4)
|
|
117
|
-
@descender = i16(t + 6)
|
|
118
|
-
@line_gap = i16(t + 8)
|
|
119
|
-
@number_of_hmetrics = u16(t + 34)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
137
|
def parse_maxp
|
|
123
138
|
@num_glyphs = u16(table_offset("maxp") + 4)
|
|
124
139
|
end
|
|
@@ -137,38 +152,6 @@ module Stationery
|
|
|
137
152
|
end
|
|
138
153
|
@glyf_offset = table_offset("glyf")
|
|
139
154
|
end
|
|
140
|
-
|
|
141
|
-
def parse_post
|
|
142
|
-
if (t = table_offset("post"))
|
|
143
|
-
@italic_angle = i16(t + 4) + (u16(t + 6) / 65_536.0)
|
|
144
|
-
@underline_position = i16(t + 8)
|
|
145
|
-
@underline_thickness = i16(t + 10)
|
|
146
|
-
@fixed_pitch = u32(t + 12) != 0
|
|
147
|
-
else
|
|
148
|
-
@italic_angle = 0
|
|
149
|
-
@underline_position = -@units_per_em / 10
|
|
150
|
-
@underline_thickness = @units_per_em / 20
|
|
151
|
-
@fixed_pitch = false
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def parse_os2
|
|
156
|
-
if (t = table_offset("OS/2"))
|
|
157
|
-
@weight = u16(t + 4)
|
|
158
|
-
@strikeout_size = i16(t + 26)
|
|
159
|
-
@strikeout_position = i16(t + 28)
|
|
160
|
-
if u16(t) >= 2
|
|
161
|
-
@x_height = i16(t + 86)
|
|
162
|
-
@cap_height = i16(t + 88)
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
@weight ||= 400
|
|
167
|
-
@strikeout_size ||= @underline_thickness
|
|
168
|
-
@strikeout_position ||= (@ascender * 0.25).round
|
|
169
|
-
@x_height ||= (@ascender * 0.5).round
|
|
170
|
-
@parse_os2 ||= (@ascender * 0.7).round
|
|
171
|
-
end
|
|
172
155
|
end
|
|
173
156
|
end
|
|
174
157
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tokenizer"
|
|
4
|
+
require_relative "tree_builder"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
# Lenient HTML to rich-text blocks (see Stationery::Rich).
|
|
8
|
+
module HTML
|
|
9
|
+
def self.parse(source) = TreeBuilder.parse(Tokenizer.tokenize(source))
|
|
10
|
+
end
|
|
11
|
+
end
|