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
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "digest/md5"
|
|
4
|
-
|
|
5
3
|
module Stationery
|
|
6
4
|
module Fonts
|
|
7
|
-
# One
|
|
8
|
-
#
|
|
5
|
+
# One font as used by one document: measures text, encodes it as
|
|
6
|
+
# character codes and remembers which glyphs were drawn so only those are
|
|
7
|
+
# embedded (TrueType) or so the widths and ToUnicode cover them (CFF).
|
|
9
8
|
#
|
|
10
|
-
# Embedded as a Type0 font
|
|
11
|
-
#
|
|
9
|
+
# Embedded as a Type0 font with Identity-H encoding and a ToUnicode map, so
|
|
10
|
+
# text extracts and searches correctly; the descendant CIDFont comes from
|
|
11
|
+
# Embedding::TrueType or Embedding::CFF.
|
|
12
12
|
class Font
|
|
13
13
|
OBLIQUE_SKEW = Math.tan(12 * Math::PI / 180)
|
|
14
14
|
|
|
@@ -17,132 +17,112 @@ module Stationery
|
|
|
17
17
|
def initialize(ttf)
|
|
18
18
|
@ttf = ttf
|
|
19
19
|
@used = {}
|
|
20
|
-
@
|
|
20
|
+
@pairs = {}
|
|
21
|
+
@glyphs = {}
|
|
22
|
+
@shapes = { true => {}, false => {} }
|
|
23
|
+
@advances = { true => {}, false => {} }
|
|
24
|
+
@kerns = { true => {}, false => {} }
|
|
25
|
+
@cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def inspect = "#<#{self.class} #{@ttf.postscript_name} used=#{@used.size}>"
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
# Advances and kerning are remembered per string, so measuring the same
|
|
31
|
+
# word again (wrapping, then laying out the line) allocates nothing.
|
|
32
|
+
# Letter spacing is added per glyph, so a ligature counts once; any
|
|
33
|
+
# letter spacing turns ligatures off, as it does when drawing.
|
|
34
|
+
def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true)
|
|
35
|
+
ligatures &&= letter_spacing.zero?
|
|
36
|
+
width = scale(advance_units(text, ligatures), size) + (letter_spacing * shape(text, ligatures).first.size)
|
|
37
|
+
return width unless kerning
|
|
38
|
+
|
|
39
|
+
width + (kerning_units(text, ligatures) * size / 1000.0)
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
def ascender(size) = scale(@ttf.ascender, size)
|
|
31
43
|
def descender(size) = -scale(@ttf.descender, size)
|
|
32
44
|
def line_gap(size) = scale(@ttf.line_gap, size)
|
|
33
45
|
def line_height(size) = ascender(size) + descender(size) + line_gap(size)
|
|
46
|
+
def x_height(size) = scale(@ttf.x_height, size)
|
|
34
47
|
def underline_position(size) = scale(@ttf.underline_position, size)
|
|
35
48
|
def underline_thickness(size) = scale(@ttf.underline_thickness, size)
|
|
36
49
|
def strikeout_position(size) = scale(@ttf.strikeout_position, size)
|
|
37
50
|
def strikeout_size(size) = scale(@ttf.strikeout_size, size)
|
|
38
|
-
def glyph?(char) = @ttf.glyph?(char)
|
|
51
|
+
def glyph?(char) = @glyphs.fetch(char) { @glyphs[char] = @ttf.glyph?(char) }
|
|
39
52
|
|
|
40
53
|
def bold?
|
|
41
54
|
@ttf.weight >= 600
|
|
42
55
|
end
|
|
43
56
|
|
|
44
|
-
def encode(text)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
def encode(text) = glyph_run(text).gids.map { |gid| code(gid) }.pack("n*")
|
|
58
|
+
|
|
59
|
+
# `ligatures:` substitutes the font's standard ligatures; `kerning:`
|
|
60
|
+
# then fills the adjustments with pair kerning between the glyphs.
|
|
61
|
+
def glyph_run(text, kerning: false, ligatures: true)
|
|
62
|
+
gids, chars = shape(text, ligatures)
|
|
63
|
+
gids.each_with_index { |gid, i| @used[gid] ||= chars[i] }
|
|
64
|
+
adjust = gids.each_with_index.map { |gid, i| kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0 }
|
|
65
|
+
GlyphRun.new(font: self, gids:, adjust:, chars:)
|
|
50
66
|
end
|
|
51
67
|
|
|
52
68
|
def used?
|
|
53
69
|
@used.any?
|
|
54
70
|
end
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
writer.add(
|
|
62
|
-
Type: :Font, Subtype: :Type0, BaseFont: name, Encoding: :"Identity-H",
|
|
63
|
-
DescendantFonts: [cid_font(writer, name, gids, subset, mapping)],
|
|
64
|
-
ToUnicode: writer.add(PDF::Stream.new(to_unicode_cmap))
|
|
65
|
-
)
|
|
72
|
+
# The two-byte character code a glyph is written as: its CID in a
|
|
73
|
+
# CID-keyed CFF font, otherwise the glyph id itself.
|
|
74
|
+
def code(gid)
|
|
75
|
+
@cid_keyed ? @ttf.cff.cid_for(gid) : gid
|
|
66
76
|
end
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
Type: :Font, Subtype: :CIDFontType2, BaseFont: name,
|
|
73
|
-
CIDSystemInfo: { Registry: "Adobe", Ordering: "Identity", Supplement: 0 },
|
|
74
|
-
FontDescriptor: descriptor(writer, name, subset),
|
|
75
|
-
DW: glyph_space(@ttf.advance(0)), W: glyph_widths(gids),
|
|
76
|
-
CIDToGIDMap: writer.add(PDF::Stream.new(cid_to_gid_map(gids, mapping)))
|
|
77
|
-
)
|
|
78
|
+
# { code => text } for every glyph drawn so far; a ligature's text is
|
|
79
|
+
# every character it stands for.
|
|
80
|
+
def used_codes
|
|
81
|
+
@used.to_h { |gid, char| [code(gid), char] }
|
|
78
82
|
end
|
|
79
83
|
|
|
80
|
-
def
|
|
84
|
+
def build(writer)
|
|
85
|
+
embedding = (@ttf.cff? ? Embedding::CFF : Embedding::TrueType).new(self)
|
|
86
|
+
name, cid_font = embedding.build(writer, @used.keys.sort)
|
|
87
|
+
|
|
81
88
|
writer.add(
|
|
82
|
-
Type: :
|
|
83
|
-
|
|
84
|
-
Ascent: glyph_space(@ttf.ascender), Descent: glyph_space(@ttf.descender),
|
|
85
|
-
CapHeight: glyph_space(@ttf.cap_height), XHeight: glyph_space(@ttf.x_height),
|
|
86
|
-
StemV: bold? ? 120 : 80,
|
|
87
|
-
FontFile2: writer.add(PDF::Stream.new(subset, { Length1: subset.bytesize }))
|
|
89
|
+
Type: :Font, Subtype: :Type0, BaseFont: name, Encoding: :"Identity-H",
|
|
90
|
+
DescendantFonts: [cid_font], ToUnicode: writer.add(PDF::Stream.new(ToUnicode.cmap(used_codes)))
|
|
88
91
|
)
|
|
89
92
|
end
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
units * size / @ttf.units_per_em.to_f
|
|
93
|
-
end
|
|
94
|
+
private
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
# [gids, source text of each glyph], remembered per string.
|
|
97
|
+
def shape(text, ligatures)
|
|
98
|
+
@shapes[ligatures][text] ||= begin
|
|
99
|
+
gids = text.each_char.map { |char| @ttf.glyph_id(char.ord) }
|
|
100
|
+
ligatures ? ligate(gids, text) : [gids, text.chars].each(&:freeze).freeze
|
|
101
|
+
end
|
|
97
102
|
end
|
|
98
103
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
def ligate(gids, text)
|
|
105
|
+
start = 0
|
|
106
|
+
glyphs = @ttf.ligatures.substitute(gids)
|
|
107
|
+
chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
|
|
108
|
+
[glyphs.map(&:first), chars].each(&:freeze).freeze
|
|
104
109
|
end
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
Digest::MD5.digest(gids.pack("n*") + @ttf.postscript_name).bytes.first(6).map { |b| (65 + (b % 26)).chr }.join
|
|
111
|
+
def advance_units(text, ligatures)
|
|
112
|
+
@advances[ligatures][text] ||= shape(text, ligatures).first.sum { |gid| @ttf.advance(gid) }
|
|
109
113
|
end
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
map = Array.new((gids.max || 0) + 1, 0)
|
|
114
|
-
gids.each { |gid| map[gid] = mapping.fetch(gid) }
|
|
115
|
-
map.pack("n*")
|
|
115
|
+
def kerning_units(text, ligatures)
|
|
116
|
+
@kerns[ligatures][text] ||= shape(text, ligatures).first.each_cons(2).sum { |left, right| pair(left, right) }
|
|
116
117
|
end
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
119
|
+
# Kerning between two glyphs in thousandths of an em.
|
|
120
|
+
def pair(left, right)
|
|
121
|
+
@pairs[(left << 16) | right] ||= @ttf.kerning.adjust(left, right) * 1000.0 / @ttf.units_per_em
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
format("<%<gid>04X> <%<utf16>s>", gid:, utf16: char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)
|
|
127
|
-
end
|
|
128
|
-
blocks = mappings.each_slice(100).map { |slice| "#{slice.size} beginbfchar\n#{slice.join("\n")}\nendbfchar" }
|
|
129
|
-
|
|
130
|
-
<<~CMAP
|
|
131
|
-
/CIDInit /ProcSet findresource begin
|
|
132
|
-
12 dict begin
|
|
133
|
-
begincmap
|
|
134
|
-
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
|
135
|
-
/CMapName /Adobe-Identity-UCS def
|
|
136
|
-
/CMapType 2 def
|
|
137
|
-
1 begincodespacerange
|
|
138
|
-
<0000> <FFFF>
|
|
139
|
-
endcodespacerange
|
|
140
|
-
#{blocks.join("\n")}
|
|
141
|
-
endcmap
|
|
142
|
-
CMapName currentdict /CMap defineresource pop
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
CMAP
|
|
124
|
+
def scale(units, size)
|
|
125
|
+
units * size / @ttf.units_per_em.to_f
|
|
146
126
|
end
|
|
147
127
|
end
|
|
148
128
|
end
|
|
@@ -6,30 +6,71 @@ module Stationery
|
|
|
6
6
|
# document gets its own book so glyph usage (and so subsetting) is per
|
|
7
7
|
# document, while the parsed TrueType data is shared through the Registry.
|
|
8
8
|
class FontBook
|
|
9
|
-
attr_reader :families
|
|
9
|
+
attr_reader :families, :fallbacks, :warnings
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# `fallbacks:` names the families tried, in order, for a character the
|
|
12
|
+
# run's own family has no glyph for.
|
|
13
|
+
def initialize(families = {}, fallbacks: [], warnings: Warnings.new)
|
|
12
14
|
@families = families.dup
|
|
15
|
+
@fallbacks = fallbacks.map(&:to_s).freeze
|
|
16
|
+
@warnings = warnings
|
|
13
17
|
@fonts = {}
|
|
18
|
+
@resolved = {}
|
|
19
|
+
@split = {}.compare_by_identity
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def inspect = "#<#{self.class} families=#{@families.keys.inspect}>"
|
|
17
23
|
|
|
18
24
|
def register(name, **paths)
|
|
19
|
-
@
|
|
25
|
+
@resolved.clear
|
|
26
|
+
@families[name.to_s] = Family.build(name, **paths)
|
|
20
27
|
end
|
|
21
28
|
|
|
22
|
-
# Returns [Font, Family::Face] for a Text::Style.
|
|
29
|
+
# Returns [Font, Family::Face] for a Text::Style, memoised per style.
|
|
23
30
|
def resolve(style)
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
@resolved[style] ||= begin
|
|
32
|
+
face = family(style.family).face(weight: style.weight, style: style.style)
|
|
33
|
+
[@fonts[face.path] ||= Font.new(Registry.load(face.path)), face].freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether `name` is registered, bundled or an installed pack.
|
|
38
|
+
def known?(name) = !(@families[name.to_s] || bundled(name) || pack(name)).nil?
|
|
39
|
+
|
|
40
|
+
# The runs split so every character is drawn by a font that has it. Runs
|
|
41
|
+
# this book already split come back as they are.
|
|
42
|
+
def fallback(runs)
|
|
43
|
+
return runs if @split.key?(runs)
|
|
44
|
+
|
|
45
|
+
(@fallback ||= Fallback.new(self)).apply(runs).tap { |split| @split[split] = true }
|
|
26
46
|
end
|
|
27
47
|
|
|
28
48
|
private
|
|
29
49
|
|
|
50
|
+
# Registered by name, bundled by name, an installed pack by name, then
|
|
51
|
+
# the first registered family, then the bundled default.
|
|
30
52
|
def family(name)
|
|
31
|
-
@families[name.to_s] ||
|
|
32
|
-
|
|
53
|
+
@families[name.to_s] || bundled(name) || pack(name) || substitute(name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# An unknown name draws with the first registered family, else bundled
|
|
57
|
+
# Inter, and says so once.
|
|
58
|
+
def substitute(name)
|
|
59
|
+
used = @families.values.first || bundled(Bundled::DEFAULT)
|
|
60
|
+
@warnings << Warnings::UnknownFamily.new(requested: name.to_s, used: used.name)
|
|
61
|
+
used
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def bundled(name)
|
|
65
|
+
@bundled ||= {}
|
|
66
|
+
@bundled[name.to_s] ||= Bundled.family(name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pack(name)
|
|
70
|
+
@packs ||= {}
|
|
71
|
+
return @packs[name.to_s] if @packs.key?(name.to_s)
|
|
72
|
+
|
|
73
|
+
@packs[name.to_s] = Packs.family(name)
|
|
33
74
|
end
|
|
34
75
|
end
|
|
35
76
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Glyph ids for one run of text in one font, with an extra advance after
|
|
6
|
+
# each glyph in thousandths of the font size (positive widens) and the
|
|
7
|
+
# source text of each glyph (several characters for a ligature). All-zero
|
|
8
|
+
# adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
|
|
9
|
+
# are written as the font's character codes (see Font#code).
|
|
10
|
+
GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
|
|
11
|
+
def width(size, letter_spacing: 0)
|
|
12
|
+
units = gids.sum { |gid| font.ttf.advance(gid) }
|
|
13
|
+
(units * size / font.ttf.units_per_em.to_f) + (adjust.sum * size / 1000.0) + (letter_spacing * gids.size)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def with_word_spacing(extra_points, size)
|
|
17
|
+
extra = extra_points * 1000.0 / size
|
|
18
|
+
with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_operator
|
|
22
|
+
return "<#{hex(gids)}> Tj" unless adjusted?
|
|
23
|
+
|
|
24
|
+
parts = gids.each_index.slice_after { |i| !adjust[i].zero? }.flat_map do |indices|
|
|
25
|
+
last = indices.last
|
|
26
|
+
chunk = ["<#{hex(indices.map { |i| gids[i] })}>"]
|
|
27
|
+
last == gids.size - 1 || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
|
|
28
|
+
end
|
|
29
|
+
"[#{parts.join(" ")}] TJ"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def adjusted? = adjust[0...-1].any? { |a| !a.zero? }
|
|
35
|
+
|
|
36
|
+
def hex(ids) = ids.map { |gid| font.code(gid) }.pack("n*").unpack1("H*").upcase
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gpos
|
|
6
|
+
# OpenType ClassDef table as a Hash of glyph id => class. Glyphs it
|
|
7
|
+
# does not list are class 0. Format 1 is a run of classes from a start
|
|
8
|
+
# glyph, format 2 ranges of glyphs sharing a class.
|
|
9
|
+
module ClassDef
|
|
10
|
+
def self.read(ttf, offset)
|
|
11
|
+
if ttf.u16(offset) == 1
|
|
12
|
+
start = ttf.u16(offset + 2)
|
|
13
|
+
classes = ttf.data.byteslice(offset + 6, ttf.u16(offset + 4) * 2).unpack("n*")
|
|
14
|
+
return classes.each_with_index.to_h { |klass, i| [start + i, klass] }.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
ranges = ttf.data.byteslice(offset + 4, ttf.u16(offset + 2) * 6).unpack("n*").each_slice(3)
|
|
18
|
+
ranges.each_with_object({}) do |(first, last, klass), map|
|
|
19
|
+
(first..last).each { |gid| map[gid] = klass }
|
|
20
|
+
end.freeze
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -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,64 @@
|
|
|
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
|
+
offsets = feature_lookups(ttf, base, "kern")
|
|
19
|
+
new(offsets.map { |offset| lookup(ttf, offset) }) if offsets.any?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Offsets of the lookups every `tag` feature record of the GSUB or GPOS
|
|
23
|
+
# table at `base` names, in LookupList order.
|
|
24
|
+
def self.feature_lookups(ttf, base, tag)
|
|
25
|
+
list = base + ttf.u16(base + 6)
|
|
26
|
+
records = Array.new(ttf.u16(list)) { |i| list + 2 + (i * 6) }
|
|
27
|
+
indices = records.select { |record| ttf.data.byteslice(record, 4) == tag }.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
|
|
31
|
+
lookups = base + ttf.u16(base + 8)
|
|
32
|
+
indices.uniq.sort.map { |i| lookups + ttf.u16(lookups + 2 + (i * 2)) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A lookup's PairPos subtables; other lookup types have none.
|
|
36
|
+
def self.lookup(ttf, offset)
|
|
37
|
+
type = ttf.u16(offset)
|
|
38
|
+
subtables = Array.new(ttf.u16(offset + 4)) { |i| offset + ttf.u16(offset + 6 + (i * 2)) }
|
|
39
|
+
subtables.filter_map do |subtable|
|
|
40
|
+
if type == PAIR
|
|
41
|
+
PairPos.read(ttf, subtable)
|
|
42
|
+
elsif type == EXTENSION && ttf.u16(subtable + 2) == PAIR
|
|
43
|
+
PairPos.read(ttf, subtable + ttf.u32(subtable + 4))
|
|
44
|
+
end
|
|
45
|
+
end.freeze
|
|
46
|
+
end
|
|
47
|
+
private_class_method :lookup
|
|
48
|
+
|
|
49
|
+
def initialize(lookups)
|
|
50
|
+
@lookups = lookups.freeze
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Within a lookup the first subtable that applies wins; lookups add up.
|
|
55
|
+
def adjust(left, right)
|
|
56
|
+
@lookups.sum do |subtables|
|
|
57
|
+
value = nil
|
|
58
|
+
subtables.find { |subtable| value = subtable.adjust(left, right) }
|
|
59
|
+
value || 0
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gsub
|
|
6
|
+
# LigatureSubst format 1 (GSUB lookup type 4): for each first glyph, the
|
|
7
|
+
# ligatures starting with it, longest first.
|
|
8
|
+
class LigatureSubst
|
|
9
|
+
def self.read(ttf, offset)
|
|
10
|
+
return unless ttf.u16(offset) == 1
|
|
11
|
+
|
|
12
|
+
sets = Gpos::Coverage.read(ttf, offset + ttf.u16(offset + 2)).each_with_index.to_h do |first, i|
|
|
13
|
+
set = offset + ttf.u16(offset + 6 + (i * 2))
|
|
14
|
+
[first, ligatures(ttf, set).sort_by.with_index { |(rest, _), j| [-rest.size, j] }.freeze]
|
|
15
|
+
end
|
|
16
|
+
new(sets)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# [[component gids after the first, ligature gid], ...]
|
|
20
|
+
def self.ligatures(ttf, set)
|
|
21
|
+
Array.new(ttf.u16(set)) do |i|
|
|
22
|
+
ligature = set + ttf.u16(set + 2 + (i * 2))
|
|
23
|
+
count = ttf.u16(ligature + 2)
|
|
24
|
+
[ttf.data.byteslice(ligature + 4, (count - 1) * 2).unpack("n*").freeze, ttf.u16(ligature)]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_class_method :ligatures
|
|
28
|
+
|
|
29
|
+
def initialize(sets)
|
|
30
|
+
@sets = sets.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# [ligature gid, glyphs consumed] for the longest ligature at `index`, or nil.
|
|
35
|
+
def match(gids, index)
|
|
36
|
+
@sets[gids[index]]&.each do |components, ligature|
|
|
37
|
+
return [ligature, components.size + 1] if gids[index + 1, components.size] == components
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Standard ligatures from the GSUB `liga` feature: LigatureSubst lookups
|
|
6
|
+
# (format 1), directly or behind Extension lookups. Only `liga` applies;
|
|
7
|
+
# contextual (`clig`) and discretionary (`dlig`) ligatures are left out.
|
|
8
|
+
# Scripts, languages and lookup flags are not distinguished.
|
|
9
|
+
class Gsub
|
|
10
|
+
LIGATURE = 4
|
|
11
|
+
EXTENSION = 7
|
|
12
|
+
|
|
13
|
+
# nil when the font has no GSUB table or no `liga` ligatures.
|
|
14
|
+
def self.parse(ttf)
|
|
15
|
+
base = ttf.table_offset("GSUB")
|
|
16
|
+
return unless base && ttf.u16(base) == 1
|
|
17
|
+
|
|
18
|
+
lookups = Gpos.feature_lookups(ttf, base, "liga").map { |offset| lookup(ttf, offset) }.reject(&:empty?)
|
|
19
|
+
new(lookups) if lookups.any?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A lookup's LigatureSubst subtables; other lookup types have none.
|
|
23
|
+
def self.lookup(ttf, offset)
|
|
24
|
+
type = ttf.u16(offset)
|
|
25
|
+
subtables = Array.new(ttf.u16(offset + 4)) { |i| offset + ttf.u16(offset + 6 + (i * 2)) }
|
|
26
|
+
subtables.filter_map do |subtable|
|
|
27
|
+
if type == LIGATURE
|
|
28
|
+
LigatureSubst.read(ttf, subtable)
|
|
29
|
+
elsif type == EXTENSION && ttf.u16(subtable + 2) == LIGATURE
|
|
30
|
+
LigatureSubst.read(ttf, subtable + ttf.u32(subtable + 4))
|
|
31
|
+
end
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
private_class_method :lookup
|
|
35
|
+
|
|
36
|
+
def initialize(lookups)
|
|
37
|
+
@lookups = lookups.freeze
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# [[gid, number of source glyphs it stands for], ...]. Lookups apply in
|
|
42
|
+
# order; within one, the first subtable matching at a position wins and
|
|
43
|
+
# its glyph is not matched again by that lookup.
|
|
44
|
+
def substitute(gids)
|
|
45
|
+
@lookups.reduce(gids.map { |gid| [gid, 1] }) { |glyphs, subtables| apply(subtables, glyphs) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def apply(subtables, glyphs)
|
|
51
|
+
ids = glyphs.map(&:first)
|
|
52
|
+
result = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < glyphs.size
|
|
55
|
+
match = nil
|
|
56
|
+
subtables.find { |subtable| match = subtable.match(ids, i) }
|
|
57
|
+
length = match ? match.last : 1
|
|
58
|
+
result << [match ? match.first : ids[i], glyphs[i, length].sum(&:last)]
|
|
59
|
+
i += length
|
|
60
|
+
end
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|