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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +358 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +25 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +8 -5
  10. data/lib/stationery/canvas.rb +25 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +53 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +52 -88
  35. data/lib/stationery/fonts/font_book.rb +46 -8
  36. data/lib/stationery/fonts/glyph_run.rb +38 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +62 -0
  41. data/lib/stationery/fonts/installer.rb +121 -0
  42. data/lib/stationery/fonts/kern_table.rb +52 -0
  43. data/lib/stationery/fonts/kerning.rb +17 -0
  44. data/lib/stationery/fonts/packs.rb +45 -0
  45. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  46. data/lib/stationery/fonts/to_unicode.rb +36 -0
  47. data/lib/stationery/fonts/true_type.rb +32 -49
  48. data/lib/stationery/html/document.rb +11 -0
  49. data/lib/stationery/html/tokenizer.rb +74 -0
  50. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  51. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  52. data/lib/stationery/html/tree_builder.rb +72 -0
  53. data/lib/stationery/html/whitespace.rb +43 -0
  54. data/lib/stationery/layout/box.rb +85 -16
  55. data/lib/stationery/layout/flow.rb +48 -15
  56. data/lib/stationery/layout/image.rb +1 -0
  57. data/lib/stationery/layout/list_item.rb +89 -0
  58. data/lib/stationery/layout/mark.rb +58 -0
  59. data/lib/stationery/layout/node.rb +26 -2
  60. data/lib/stationery/layout/paginator.rb +35 -6
  61. data/lib/stationery/layout/positioned.rb +3 -1
  62. data/lib/stationery/layout/row.rb +34 -4
  63. data/lib/stationery/layout/svg.rb +45 -0
  64. data/lib/stationery/layout/table/cell.rb +33 -6
  65. data/lib/stationery/layout/table/grid.rb +93 -0
  66. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  67. data/lib/stationery/layout/table/selection.rb +3 -2
  68. data/lib/stationery/layout/table.rb +69 -23
  69. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  70. data/lib/stationery/layout/table_of_contents.rb +43 -0
  71. data/lib/stationery/layout/text.rb +5 -4
  72. data/lib/stationery/layout/wrap.rb +78 -0
  73. data/lib/stationery/list_markers.rb +47 -0
  74. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  75. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  76. data/lib/stationery/markdown/block_parser.rb +141 -0
  77. data/lib/stationery/markdown/document.rb +50 -0
  78. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  79. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  80. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  81. data/lib/stationery/markdown/inline_parser.rb +89 -0
  82. data/lib/stationery/minitest.rb +33 -0
  83. data/lib/stationery/outline.rb +37 -0
  84. data/lib/stationery/page.rb +18 -4
  85. data/lib/stationery/page_templates.rb +33 -10
  86. data/lib/stationery/pdf/assembler.rb +18 -7
  87. data/lib/stationery/pdf/outline_writer.rb +72 -0
  88. data/lib/stationery/pdf/serializer.rb +5 -1
  89. data/lib/stationery/pdf/writer.rb +4 -0
  90. data/lib/stationery/preview.rb +64 -0
  91. data/lib/stationery/rails/previews_controller.rb +61 -0
  92. data/lib/stationery/rails.rb +2 -0
  93. data/lib/stationery/railtie.rb +60 -0
  94. data/lib/stationery/regions.rb +67 -0
  95. data/lib/stationery/rich/nodes.rb +55 -0
  96. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  97. data/lib/stationery/rich/renderer.rb +121 -0
  98. data/lib/stationery/rich/styles.rb +34 -0
  99. data/lib/stationery/rspec.rb +6 -0
  100. data/lib/stationery/structure.rb +78 -0
  101. data/lib/stationery/svg/arc.rb +91 -0
  102. data/lib/stationery/svg/document.rb +73 -0
  103. data/lib/stationery/svg/parser.rb +38 -0
  104. data/lib/stationery/svg/path_data.rb +137 -0
  105. data/lib/stationery/svg/shapes.rb +54 -0
  106. data/lib/stationery/svg/style.rb +68 -0
  107. data/lib/stationery/svg/transform.rb +43 -0
  108. data/lib/stationery/testing/inspector.rb +98 -0
  109. data/lib/stationery/testing/matchers.rb +123 -0
  110. data/lib/stationery/text/entities/html4.rb +49 -0
  111. data/lib/stationery/text/entities.rb +6 -1
  112. data/lib/stationery/text/line.rb +8 -2
  113. data/lib/stationery/text/paragraph.rb +32 -11
  114. data/lib/stationery/text/runs_builder.rb +9 -1
  115. data/lib/stationery/text/style.rb +2 -2
  116. data/lib/stationery/text/wrapper.rb +3 -2
  117. data/lib/stationery/version.rb +1 -1
  118. data/lib/stationery/warnings.rb +65 -0
  119. data/lib/stationery.rb +51 -0
  120. 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 TrueType (.ttf) font needed to measure text and to
6
- # embed a subset of it. Static TrueType outlines only: CFF (.otf), font
7
- # collections and web font wrappers are rejected with a named reason.
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
- REQUIRED_TABLES = %w[head hhea maxp hmtx loca glyf].freeze
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 ["\x00\x01\x00\x00".b, "true"].include?(signature)
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
- required = cmap ? REQUIRED_TABLES + ["cmap"] : REQUIRED_TABLES
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