stationery 0.1.0 → 0.3.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 +114 -1
- data/README.md +395 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +26 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +10 -6
- data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
- data/lib/stationery/fonts/font_book.rb +49 -8
- data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -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/ligatures.rb +16 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- 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 +64 -57
- data/lib/stationery/fonts/woff.rb +52 -0
- 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 +102 -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 +48 -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 +6 -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 +20 -8
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +22 -10
- data/lib/stationery/pdf/writer.rb +28 -5
- 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/resources.rb +8 -2
- 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/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +103 -0
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +58 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +85 -0
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +54 -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 +33 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +4 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +67 -0
- metadata +101 -4
|
@@ -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,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Chooses where a font's ligatures come from. Every source answers
|
|
6
|
+
# `substitute(gids)` with [[gid, source glyph count], ...].
|
|
7
|
+
module Ligatures
|
|
8
|
+
# For fonts without ligatures: every glyph stands for itself.
|
|
9
|
+
module NONE
|
|
10
|
+
def self.substitute(gids) = gids.map { |gid| [gid, 1] }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.for(ttf) = Gsub.parse(ttf) || NONE
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
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
|
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
|
-
# Process-wide cache of parsed TrueType files, keyed by path and
|
|
6
|
-
# edited font is re-read. Parsing is the expensive part; the
|
|
7
|
-
# Font objects that track used glyphs are cheap wrappers
|
|
5
|
+
# Process-wide cache of parsed TrueType files, keyed by path, face and
|
|
6
|
+
# mtime so an edited font is re-read. Parsing is the expensive part; the
|
|
7
|
+
# per-document Font objects that track used glyphs are cheap wrappers
|
|
8
|
+
# around a parse. A `#N` suffix on the path picks face N of a collection.
|
|
8
9
|
module Registry
|
|
9
10
|
SIZE = 16
|
|
11
|
+
FACE = /\A(.+)#(\d+)\z/
|
|
10
12
|
@cache = {}
|
|
11
13
|
@mutex = Mutex.new
|
|
12
14
|
|
|
13
15
|
class << self
|
|
14
16
|
def load(path)
|
|
15
|
-
path =
|
|
17
|
+
path, index = split(path.to_s)
|
|
18
|
+
path = File.expand_path(path)
|
|
16
19
|
raise UnsupportedFont, "font file not found: #{path}" unless File.file?(path)
|
|
17
20
|
|
|
18
21
|
mtime = File.mtime(path)
|
|
19
|
-
@mutex.synchronize { fetch(path, mtime) }
|
|
22
|
+
@mutex.synchronize { fetch(path, index, mtime) }
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def clear
|
|
@@ -25,10 +28,16 @@ module Stationery
|
|
|
25
28
|
|
|
26
29
|
private
|
|
27
30
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
def split(path)
|
|
32
|
+
match = FACE.match(path)
|
|
33
|
+
match ? [match[1], match[2].to_i] : [path, 0]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch(path, index, mtime)
|
|
37
|
+
key = [path, index]
|
|
38
|
+
cached_mtime, ttf = @cache.delete(key)
|
|
39
|
+
ttf = TrueType.new(File.binread(path), index:) unless cached_mtime == mtime
|
|
40
|
+
@cache[key] = [mtime, ttf]
|
|
32
41
|
@cache.shift while @cache.size > SIZE
|
|
33
42
|
ttf
|
|
34
43
|
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 => text }; a ligature maps to several characters.
|
|
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,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
|
-
# Reads the tables of a
|
|
6
|
-
# embed
|
|
7
|
-
#
|
|
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), alone or as one
|
|
7
|
+
# face of a collection (.ttc), or unwrapped from a WOFF web font. Variable
|
|
8
|
+
# CFF2 fonts and WOFF2 are rejected with a named 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
|
|
16
|
+
COLLECTION = "ttcf"
|
|
10
17
|
SIGNATURES = {
|
|
11
|
-
"
|
|
12
|
-
"ttcf" => "TrueType collections (.ttc) are not supported, use a single TrueType (.ttf) font",
|
|
13
|
-
"wOFF" => "WOFF web fonts are not supported, use the TrueType (.ttf) file",
|
|
14
|
-
"wOF2" => "WOFF2 web fonts are not supported, use the TrueType (.ttf) file"
|
|
18
|
+
"wOF2" => "WOFF2 needs Brotli; convert to .ttf or .woff"
|
|
15
19
|
}.freeze
|
|
16
20
|
|
|
17
21
|
attr_reader :data, :tables, :units_per_em, :bbox, :ascender, :descender, :line_gap, :num_glyphs,
|
|
@@ -19,22 +23,31 @@ module Stationery
|
|
|
19
23
|
:strikeout_position, :strikeout_size, :cap_height, :x_height, :weight, :postscript_name
|
|
20
24
|
|
|
21
25
|
# A subset embedded in a PDF carries no cmap (the PDF maps glyphs itself),
|
|
22
|
-
# so reading one back passes `cmap: false`.
|
|
23
|
-
|
|
26
|
+
# so reading one back passes `cmap: false`. `index:` picks a face of a
|
|
27
|
+
# collection, face 0 by default.
|
|
28
|
+
def initialize(data, cmap: true, index: nil)
|
|
24
29
|
@data = data.b
|
|
30
|
+
@data = WOFF.unpack(@data) if @data.start_with?(WOFF::SIGNATURE)
|
|
31
|
+
@sfnt = face_offset(index || 0)
|
|
25
32
|
check_signature
|
|
26
33
|
read_table_directory(cmap)
|
|
27
34
|
parse_head
|
|
28
35
|
parse_hhea
|
|
29
36
|
parse_maxp
|
|
30
37
|
parse_hmtx
|
|
31
|
-
parse_loca
|
|
38
|
+
parse_loca unless cff?
|
|
32
39
|
@cmap = cmap ? Cmap.parse(self) : {}
|
|
33
40
|
parse_post
|
|
34
41
|
parse_os2
|
|
35
42
|
@postscript_name = NameTable.postscript_name(self)
|
|
36
43
|
end
|
|
37
44
|
|
|
45
|
+
def self.collection?(data) = data.byteslice(0, 4) == COLLECTION
|
|
46
|
+
|
|
47
|
+
def self.faces(data)
|
|
48
|
+
collection?(data) ? data.byteslice(8, 4).unpack1("N") : 1
|
|
49
|
+
end
|
|
50
|
+
|
|
38
51
|
def inspect = "#<#{self.class} #{@postscript_name} glyphs=#{@num_glyphs}>"
|
|
39
52
|
|
|
40
53
|
def glyph_id(codepoint)
|
|
@@ -57,6 +70,26 @@ module Stationery
|
|
|
57
70
|
end
|
|
58
71
|
end
|
|
59
72
|
|
|
73
|
+
# Pair kerning, read on first use. Shared by every document using this
|
|
74
|
+
# parse; the source is immutable once built.
|
|
75
|
+
def kerning
|
|
76
|
+
@kerning ||= Kerning.for(self)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Standard ligatures, read on first use and shared like #kerning.
|
|
80
|
+
def ligatures
|
|
81
|
+
@ligatures ||= Ligatures.for(self)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cff?
|
|
85
|
+
@tables.key?("CFF ")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The CFF table, read on first use; nil for TrueType outlines.
|
|
89
|
+
def cff
|
|
90
|
+
@cff ||= CFF.new(table_data("CFF ")) if cff?
|
|
91
|
+
end
|
|
92
|
+
|
|
60
93
|
def fixed_pitch?
|
|
61
94
|
@fixed_pitch
|
|
62
95
|
end
|
|
@@ -86,22 +119,36 @@ module Stationery
|
|
|
86
119
|
|
|
87
120
|
private
|
|
88
121
|
|
|
122
|
+
# Table offsets in a collection are already absolute, so a face is read
|
|
123
|
+
# from the shared data at its own table directory.
|
|
124
|
+
def face_offset(index)
|
|
125
|
+
count = self.class.faces(@data)
|
|
126
|
+
unless index.between?(0, count - 1)
|
|
127
|
+
raise ArgumentError, "font face #{index} is out of range (numFonts #{count})"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
self.class.collection?(@data) ? u32(12 + (index * 4)) : 0
|
|
131
|
+
end
|
|
132
|
+
|
|
89
133
|
def check_signature
|
|
90
|
-
signature = @data.byteslice(
|
|
91
|
-
return if
|
|
134
|
+
signature = @data.byteslice(@sfnt, 4)
|
|
135
|
+
return if SFNT_VERSIONS.include?(signature)
|
|
92
136
|
|
|
93
137
|
raise UnsupportedFont, SIGNATURES.fetch(signature, "not a TrueType font")
|
|
94
138
|
end
|
|
95
139
|
|
|
96
140
|
def read_table_directory(cmap)
|
|
97
141
|
@tables = {}
|
|
98
|
-
u16(4).times do |i|
|
|
99
|
-
record = 12 + (i * 16)
|
|
142
|
+
u16(@sfnt + 4).times do |i|
|
|
143
|
+
record = @sfnt + 12 + (i * 16)
|
|
100
144
|
@tables[@data.byteslice(record, 4)] = [u32(record + 8), u32(record + 12)]
|
|
101
145
|
end
|
|
102
|
-
|
|
146
|
+
raise UnsupportedFont, "variable CFF2 fonts are not supported" if @tables.key?("CFF2")
|
|
147
|
+
|
|
148
|
+
required = REQUIRED_TABLES + OUTLINE_TABLES.fetch(@data.byteslice(@sfnt, 4), GLYF_TABLES)
|
|
149
|
+
required += ["cmap"] if cmap
|
|
103
150
|
missing = required.reject { |tag| @tables.key?(tag) }
|
|
104
|
-
raise UnsupportedFont, "font is missing the #{missing.join(", ")} table" if missing.any?
|
|
151
|
+
raise UnsupportedFont, "font is missing the #{missing.map(&:strip).join(", ")} table" if missing.any?
|
|
105
152
|
end
|
|
106
153
|
|
|
107
154
|
def parse_head
|
|
@@ -111,14 +158,6 @@ module Stationery
|
|
|
111
158
|
@index_to_loc_format = i16(t + 50)
|
|
112
159
|
end
|
|
113
160
|
|
|
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
161
|
def parse_maxp
|
|
123
162
|
@num_glyphs = u16(table_offset("maxp") + 4)
|
|
124
163
|
end
|
|
@@ -137,38 +176,6 @@ module Stationery
|
|
|
137
176
|
end
|
|
138
177
|
@glyf_offset = table_offset("glyf")
|
|
139
178
|
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
179
|
end
|
|
173
180
|
end
|
|
174
181
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module Fonts
|
|
7
|
+
# Unwraps a WOFF 1.0 web font into the sfnt it was made from: each table is
|
|
8
|
+
# inflated (or copied when stored) and laid out behind a fresh table
|
|
9
|
+
# directory, 4-byte aligned in directory order. Metadata and private data
|
|
10
|
+
# blocks are dropped.
|
|
11
|
+
module WOFF
|
|
12
|
+
SIGNATURE = "wOFF"
|
|
13
|
+
HEADER = 44
|
|
14
|
+
ENTRY = 20
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def unpack(data)
|
|
19
|
+
data = data.b
|
|
20
|
+
flavor = data.byteslice(4, 4)
|
|
21
|
+
tables = Array.new(data.byteslice(12, 2).unpack1("n")) do |i|
|
|
22
|
+
tag, offset, compressed, length, checksum = data.byteslice(HEADER + (i * ENTRY), ENTRY).unpack("a4NNNN")
|
|
23
|
+
[tag, checksum, table(tag, data.byteslice(offset, compressed), length)]
|
|
24
|
+
end
|
|
25
|
+
assemble(flavor, tables)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def table(tag, bytes, length)
|
|
29
|
+
return bytes if bytes.bytesize >= length
|
|
30
|
+
|
|
31
|
+
Zlib::Inflate.inflate(bytes)
|
|
32
|
+
rescue Zlib::Error
|
|
33
|
+
raise UnsupportedFont, "corrupt WOFF table #{tag}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def assemble(flavor, tables)
|
|
37
|
+
entry_selector = Math.log2(tables.size).floor
|
|
38
|
+
search_range = (2**entry_selector) * 16
|
|
39
|
+
directory = [flavor, tables.size, search_range, entry_selector, (tables.size * 16) - search_range]
|
|
40
|
+
.pack("a4nnnn")
|
|
41
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
42
|
+
offset = 12 + (tables.size * 16)
|
|
43
|
+
|
|
44
|
+
tables.each do |tag, checksum, bytes|
|
|
45
|
+
directory << [tag, checksum, offset + body.bytesize, bytes.bytesize].pack("a4NNN")
|
|
46
|
+
body << bytes << ("\0".b * (-bytes.bytesize % 4))
|
|
47
|
+
end
|
|
48
|
+
directory << body
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
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
|