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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -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 +10 -6
  10. data/lib/stationery/canvas.rb +38 -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 +60 -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 +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -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 +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. 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 mtime so an
6
- # edited font is re-read. Parsing is the expensive part; the per-document
7
- # Font objects that track used glyphs are cheap wrappers around a parse.
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 = File.expand_path(path.to_s)
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 fetch(path, mtime)
29
- cached_mtime, ttf = @cache.delete(path)
30
- ttf = TrueType.new(File.binread(path)) unless cached_mtime == mtime
31
- @cache[path] = [mtime, ttf]
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 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), 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
- 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
16
+ COLLECTION = "ttcf"
10
17
  SIGNATURES = {
11
- "OTTO" => "OpenType fonts with CFF outlines (.otf) are not supported, use a TrueType (.ttf) font",
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
- def initialize(data, cmap: true)
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(0, 4)
91
- return if ["\x00\x01\x00\x00".b, "true"].include?(signature)
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
- required = cmap ? REQUIRED_TABLES + ["cmap"] : REQUIRED_TABLES
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