stationery 0.1.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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +145 -0
  5. data/lib/stationery/builder.rb +61 -0
  6. data/lib/stationery/canvas/path.rb +82 -0
  7. data/lib/stationery/canvas/text.rb +55 -0
  8. data/lib/stationery/canvas.rb +121 -0
  9. data/lib/stationery/color.rb +62 -0
  10. data/lib/stationery/component.rb +54 -0
  11. data/lib/stationery/document.rb +95 -0
  12. data/lib/stationery/elements.rb +90 -0
  13. data/lib/stationery/errors.rb +11 -0
  14. data/lib/stationery/fonts/cmap.rb +75 -0
  15. data/lib/stationery/fonts/family.rb +51 -0
  16. data/lib/stationery/fonts/font.rb +149 -0
  17. data/lib/stationery/fonts/font_book.rb +36 -0
  18. data/lib/stationery/fonts/name_table.rb +39 -0
  19. data/lib/stationery/fonts/registry.rb +38 -0
  20. data/lib/stationery/fonts/subset.rb +139 -0
  21. data/lib/stationery/fonts/true_type.rb +174 -0
  22. data/lib/stationery/geometry.rb +56 -0
  23. data/lib/stationery/images/cache.rb +32 -0
  24. data/lib/stationery/images/images.rb +45 -0
  25. data/lib/stationery/images/jpeg.rb +53 -0
  26. data/lib/stationery/images/png.rb +119 -0
  27. data/lib/stationery/images/scanlines.rb +56 -0
  28. data/lib/stationery/layout/box.rb +134 -0
  29. data/lib/stationery/layout/flow.rb +121 -0
  30. data/lib/stationery/layout/image.rb +53 -0
  31. data/lib/stationery/layout/leaves.rb +58 -0
  32. data/lib/stationery/layout/node.rb +46 -0
  33. data/lib/stationery/layout/paginator.rb +41 -0
  34. data/lib/stationery/layout/positioned.rb +24 -0
  35. data/lib/stationery/layout/row.rb +71 -0
  36. data/lib/stationery/layout/table/cell.rb +93 -0
  37. data/lib/stationery/layout/table/selection.rb +52 -0
  38. data/lib/stationery/layout/table/widths.rb +53 -0
  39. data/lib/stationery/layout/table.rb +118 -0
  40. data/lib/stationery/layout/text.rb +62 -0
  41. data/lib/stationery/page.rb +47 -0
  42. data/lib/stationery/page_templates.rb +36 -0
  43. data/lib/stationery/pdf/assembler.rb +53 -0
  44. data/lib/stationery/pdf/serializer.rb +56 -0
  45. data/lib/stationery/pdf/stream.rb +27 -0
  46. data/lib/stationery/pdf/types.rb +15 -0
  47. data/lib/stationery/pdf/writer.rb +59 -0
  48. data/lib/stationery/rails.rb +20 -0
  49. data/lib/stationery/resources.rb +34 -0
  50. data/lib/stationery/text/entities.rb +23 -0
  51. data/lib/stationery/text/line.rb +26 -0
  52. data/lib/stationery/text/markup.rb +103 -0
  53. data/lib/stationery/text/paragraph.rb +98 -0
  54. data/lib/stationery/text/runs_builder.rb +52 -0
  55. data/lib/stationery/text/style.rb +46 -0
  56. data/lib/stationery/text/wrapper.rb +131 -0
  57. data/lib/stationery/version.rb +5 -0
  58. data/lib/stationery.rb +58 -0
  59. metadata +108 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # A whole PDF. Configure it at class level and describe it in view_template:
5
+ #
6
+ # class Invoice < Stationery::Document
7
+ # page size: :a4, margin: 40
8
+ # font_family "Inter", regular: "Inter-Regular.ttf", bold: "Inter-Bold.ttf"
9
+ # default_text font: "Inter", size: 9
10
+ # metadata title: "Invoice"
11
+ # page_template { |page| box(at: [40, page.height - 30]) { text "#{page.number}/#{page.count}" } }
12
+ #
13
+ # def view_template = text("Hello")
14
+ # end
15
+ #
16
+ # Invoice.new.to_pdf # => "%PDF-1.7..."
17
+ class Document < Component
18
+ INFO_KEYS = { title: :Title, author: :Author, subject: :Subject, keywords: :Keywords, creator: :Creator,
19
+ producer: :Producer }.freeze
20
+
21
+ class << self
22
+ def config
23
+ @config ||= if superclass.respond_to?(:config)
24
+ superclass.config.transform_values(&:dup)
25
+ else
26
+ { page: { size: :letter, margin: 36 }, families: {}, text: {}, metadata: {}, templates: [] }
27
+ end
28
+ end
29
+
30
+ def page(size: :letter, margin: 36, layout: :portrait)
31
+ config[:page] = { size:, margin:, layout: }
32
+ end
33
+
34
+ def font_family(name, **paths)
35
+ config[:families][name.to_s] = Fonts::Family.new(name, **paths)
36
+ end
37
+
38
+ def default_text(**options)
39
+ config[:text] = config[:text].merge(options)
40
+ end
41
+
42
+ def metadata(**info)
43
+ config[:metadata] = config[:metadata].merge(info)
44
+ end
45
+
46
+ # Runs after pagination on every page. `layer: :background` paints under
47
+ # the page's content.
48
+ def page_template(layer: :foreground, &block)
49
+ config[:templates] << [layer, block]
50
+ end
51
+ end
52
+
53
+ attr_reader :warnings
54
+
55
+ def page_options = self.class.config[:page]
56
+ def metadata = self.class.config[:metadata]
57
+
58
+ def to_pdf(target = nil)
59
+ book = Fonts::FontBook.new(self.class.config[:families])
60
+ call(builder = Builder.new(book:, text: self.class.config[:text]))
61
+ resources = Resources.new
62
+ paginator = Layout::Paginator.new(resources:, page: page_options)
63
+ pages = paginator.paginate(builder.root)
64
+ @warnings = paginator.warnings
65
+ PageTemplates.new(self, book:, resources:).apply(pages)
66
+ write(PDF::Assembler.new(pages:, resources:, info:).render, target)
67
+ end
68
+
69
+ # Used by page templates to build nodes into their own root.
70
+ def build_with(builder)
71
+ previous = @_builder
72
+ @_builder = builder
73
+ yield
74
+ ensure
75
+ @_builder = previous
76
+ end
77
+
78
+ private
79
+
80
+ def info
81
+ metadata.to_h do |key, value|
82
+ [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
83
+ end
84
+ end
85
+
86
+ def write(pdf, target)
87
+ if target.respond_to?(:write)
88
+ target.write(pdf)
89
+ elsif target
90
+ File.binwrite(target.to_s, pdf)
91
+ end
92
+ pdf
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # The element DSL available inside every component's view_template.
5
+ module Elements
6
+ PARAGRAPH_DEFAULTS = { align: :left, leading: 0 }.freeze
7
+
8
+ # A paragraph. Plain strings are always literal; pass `markup: true` to
9
+ # read inline tags, or a block to build styled runs in Ruby.
10
+ def text(content = nil, markup: false, keep_with_next: nil, break_inside: nil, **options, &)
11
+ settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading)).merge(options)
12
+ style = @_builder.style(options)
13
+ runs = text_runs(content, style, markup, &)
14
+ node = Layout::Text.new(runs, context: @_builder.context(style), align: settings[:align],
15
+ leading: settings[:leading])
16
+ node.keep_with_next = keep_with_next
17
+ node.break_inside = break_inside
18
+ @_builder.add(node)
19
+ end
20
+
21
+ # A container with padding, background, border and radius. `at: [x, y]`
22
+ # places it at a fixed page position outside the flow.
23
+ def box(at: nil, align: nil, gap: 0, width: nil, keep_with_next: nil, **, &)
24
+ node = Layout::Box.new(container(align:, gap:, &), width:, **)
25
+ node.keep_with_next = keep_with_next
26
+ @_builder.add(at ? Layout::Positioned.new(node, x: at[0], y: at[1], width:) : node)
27
+ end
28
+
29
+ def row(gap: 0, align: :top)
30
+ columns = Builder::Columns.new
31
+ @_builder.within(columns) { yield if block_given? }
32
+ @_builder.add(Layout::Row.new(columns.nodes, gap:, align:))
33
+ end
34
+
35
+ # A row column: `width:` in points, as a fraction (0.5), :auto or nil for
36
+ # an equal share. Takes every box option.
37
+ def column(width: nil, align: nil, gap: 0, **, &)
38
+ @_builder.add(Layout::Box.new(container(align:, gap:, &), width:, **))
39
+ end
40
+
41
+ # Children kept in one vertical group; `keep_together: true` moves the
42
+ # whole group to the next page rather than splitting it.
43
+ def group(gap: 0, keep_together: false, keep_with_next: nil, &)
44
+ flow = container(gap:, &)
45
+ flow.break_inside = :avoid if keep_together
46
+ flow.keep_with_next = keep_with_next
47
+ @_builder.add(flow)
48
+ end
49
+
50
+ def table(rows, widths: nil, width: :auto, header: false, cell: {}, &)
51
+ @_builder.add(Layout::Table.new(rows, context: @_builder.context, widths:, width:, header:, cell:, &))
52
+ end
53
+
54
+ def image(source, align: nil, **)
55
+ node = Layout::Image.new(source, **)
56
+ @_builder.add(align ? Layout::Flow.new([node], align:) : node)
57
+ end
58
+
59
+ def rule(**) = @_builder.add(Layout::Rule.new(**))
60
+ def spacer(height) = @_builder.add(Layout::Spacer.new(height))
61
+ def page_break = @_builder.add(Layout::PageBreak.new)
62
+
63
+ # Draw directly: the block receives the canvas and the reserved rectangle.
64
+ def canvas(height:, at: nil, width: nil, &)
65
+ node = Layout::CanvasNode.new(height:, &)
66
+ @_builder.add(at ? Layout::Positioned.new(node, x: at[0], y: at[1], width:) : node)
67
+ end
68
+
69
+ # Text defaults (font, size, colour, weight, align, leading, …) for a block.
70
+ def text_style(**, &)
71
+ @_builder.with_text(**, &)
72
+ end
73
+
74
+ private
75
+
76
+ def container(align: nil, gap: 0, &)
77
+ flow = Layout::Flow.new([], gap:, align: align || :left)
78
+ @_builder.within(flow) do
79
+ align ? @_builder.with_text(align:) { yield_content(&) } : yield_content(&)
80
+ end
81
+ end
82
+
83
+ def text_runs(content, style, markup, &)
84
+ return Text::RunsBuilder.build(style, &) if block_given?
85
+ return Text::Markup.parse(content, style) if markup
86
+
87
+ [Text::Run.new(content.to_s, style)]
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ class Error < StandardError; end
5
+
6
+ # A font file stationery cannot read or embed (CFF, collections, WOFF, …).
7
+ class UnsupportedFont < Error; end
8
+
9
+ # An image format stationery cannot embed (WebP, GIF, interlaced PNG, …).
10
+ class UnsupportedImage < Error; end
11
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # Parses a font's Unicode character map (formats 4 and 12) into a Hash of
6
+ # codepoint => glyph id.
7
+ module Cmap
8
+ module_function
9
+
10
+ def parse(ttf)
11
+ t = ttf.table_offset("cmap")
12
+ subtables = Array.new(ttf.u16(t + 2)) do |i|
13
+ record = t + 4 + (i * 8)
14
+ offset = t + ttf.u32(record + 4)
15
+ [ttf.u16(record), ttf.u16(record + 2), offset, ttf.u16(offset)]
16
+ end
17
+
18
+ best = preferred(subtables)
19
+ raise UnsupportedFont, "font has no Unicode character map" unless best
20
+
21
+ best[3] == 12 ? format12(ttf, best[2]) : format4(ttf, best[2])
22
+ end
23
+
24
+ # Full Unicode (format 12) wins over the Basic Multilingual Plane (format 4).
25
+ def preferred(subtables)
26
+ subtables.find { |platform, encoding, _, format| platform == 3 && encoding == 10 && format == 12 } ||
27
+ subtables.find { |platform, _, _, format| platform.zero? && format == 12 } ||
28
+ subtables.find { |platform, encoding, _, format| platform == 3 && encoding == 1 && format == 4 } ||
29
+ subtables.find { |platform, _, _, format| platform.zero? && format == 4 }
30
+ end
31
+
32
+ def format4(ttf, offset)
33
+ map = {}
34
+ seg_count = ttf.u16(offset + 6) / 2
35
+ ends = offset + 14
36
+ starts = ends + (seg_count * 2) + 2
37
+ deltas = starts + (seg_count * 2)
38
+ range_offsets = deltas + (seg_count * 2)
39
+
40
+ seg_count.times do |i|
41
+ range = ttf.u16(starts + (i * 2))..ttf.u16(ends + (i * 2))
42
+ format4_segment(ttf, map, range, ttf.u16(deltas + (i * 2)), range_offsets + (i * 2))
43
+ end
44
+ map
45
+ end
46
+
47
+ def format4_segment(ttf, map, range, delta, range_offset_pos)
48
+ range_offset = ttf.u16(range_offset_pos)
49
+ first = range.begin
50
+ range.each do |codepoint|
51
+ next if codepoint == 0xFFFF
52
+
53
+ gid = if range_offset.zero?
54
+ (codepoint + delta) & 0xFFFF
55
+ else
56
+ glyph = ttf.u16(range_offset_pos + range_offset + ((codepoint - first) * 2))
57
+ glyph.zero? ? 0 : (glyph + delta) & 0xFFFF
58
+ end
59
+ map[codepoint] = gid unless gid.zero?
60
+ end
61
+ end
62
+
63
+ def format12(ttf, offset)
64
+ map = {}
65
+ ttf.u32(offset + 12).times do |i|
66
+ group = offset + 16 + (i * 12)
67
+ first = ttf.u32(group)
68
+ gid = ttf.u32(group + 8)
69
+ (first..ttf.u32(group + 4)).each { |codepoint| map[codepoint] = gid + codepoint - first }
70
+ end
71
+ map
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # A named set of up to four TrueType files. A style without its own file is
6
+ # drawn from the nearest one with synthetic bold (stroked outlines) and/or
7
+ # synthetic oblique (sheared text matrix).
8
+ class Family
9
+ STYLES = %i[regular bold italic bold_italic].freeze
10
+
11
+ Face = Data.define(:path, :synthetic_bold, :synthetic_oblique)
12
+
13
+ attr_reader :name, :paths
14
+
15
+ def initialize(name, **paths)
16
+ unknown = paths.keys - STYLES
17
+ raise ArgumentError, "unknown font style #{unknown.join(", ")} (use #{STYLES.join(", ")})" if unknown.any?
18
+ raise ArgumentError, "font family #{name} needs a regular face" unless paths[:regular]
19
+
20
+ @name = name.to_s
21
+ @paths = paths.transform_values(&:to_s).freeze
22
+ end
23
+
24
+ def face(weight: :regular, style: :normal)
25
+ bold = bold?(weight)
26
+ italic = style.to_sym == :italic
27
+ candidates(bold, italic).each do |key, synthetic_bold, synthetic_oblique|
28
+ return Face.new(@paths[key], synthetic_bold, synthetic_oblique) if @paths[key]
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def bold?(weight)
35
+ weight.is_a?(Numeric) ? weight >= 600 : %i[bold semibold].include?(weight.to_sym)
36
+ end
37
+
38
+ def candidates(bold, italic)
39
+ if bold && italic
40
+ [[:bold_italic, false, false], [:bold, false, true], [:italic, true, false], [:regular, true, true]]
41
+ elsif bold
42
+ [[:bold, false, false], [:regular, true, false]]
43
+ elsif italic
44
+ [[:italic, false, false], [:regular, false, true]]
45
+ else
46
+ [[:regular, false, false]]
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+
5
+ module Stationery
6
+ module Fonts
7
+ # One TrueType font as used by one document: measures text, encodes it as
8
+ # glyph ids and remembers which glyphs were drawn so only those are embedded.
9
+ #
10
+ # Embedded as a Type0 font over a CIDFontType2 with Identity-H encoding and a
11
+ # ToUnicode map, so text extracts and searches correctly.
12
+ class Font
13
+ OBLIQUE_SKEW = Math.tan(12 * Math::PI / 180)
14
+
15
+ attr_reader :ttf
16
+
17
+ def initialize(ttf)
18
+ @ttf = ttf
19
+ @used = {}
20
+ @widths = {}
21
+ end
22
+
23
+ def inspect = "#<#{self.class} #{@ttf.postscript_name} used=#{@used.size}>"
24
+
25
+ def width_of(text, size, letter_spacing: 0)
26
+ units = text.each_char.sum { |char| @widths[char] ||= @ttf.advance(@ttf.glyph_id(char.ord)) }
27
+ scale(units, size) + (letter_spacing * text.length)
28
+ end
29
+
30
+ def ascender(size) = scale(@ttf.ascender, size)
31
+ def descender(size) = -scale(@ttf.descender, size)
32
+ def line_gap(size) = scale(@ttf.line_gap, size)
33
+ def line_height(size) = ascender(size) + descender(size) + line_gap(size)
34
+ def underline_position(size) = scale(@ttf.underline_position, size)
35
+ def underline_thickness(size) = scale(@ttf.underline_thickness, size)
36
+ def strikeout_position(size) = scale(@ttf.strikeout_position, size)
37
+ def strikeout_size(size) = scale(@ttf.strikeout_size, size)
38
+ def glyph?(char) = @ttf.glyph?(char)
39
+
40
+ def bold?
41
+ @ttf.weight >= 600
42
+ end
43
+
44
+ def encode(text)
45
+ text.each_char.map do |char|
46
+ gid = @ttf.glyph_id(char.ord)
47
+ @used[gid] ||= char
48
+ gid
49
+ end.pack("n*")
50
+ end
51
+
52
+ def used?
53
+ @used.any?
54
+ end
55
+
56
+ def build(writer)
57
+ gids = @used.keys.sort
58
+ subset, mapping = Subset.build(@ttf, gids)
59
+ name = :"#{subset_tag(gids)}+#{@ttf.postscript_name}"
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
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def cid_font(writer, name, gids, subset, mapping)
71
+ writer.add(
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
+ end
79
+
80
+ def descriptor(writer, name, subset)
81
+ writer.add(
82
+ Type: :FontDescriptor, FontName: name, Flags: flags,
83
+ FontBBox: @ttf.bbox.map { |v| glyph_space(v) }, ItalicAngle: @ttf.italic_angle,
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 }))
88
+ )
89
+ end
90
+
91
+ def scale(units, size)
92
+ units * size / @ttf.units_per_em.to_f
93
+ end
94
+
95
+ def glyph_space(units)
96
+ (units * 1000.0 / @ttf.units_per_em).round
97
+ end
98
+
99
+ def flags
100
+ flags = 32 # Nonsymbolic
101
+ flags |= 1 if @ttf.fixed_pitch?
102
+ flags |= 64 unless @ttf.italic_angle.zero?
103
+ flags
104
+ end
105
+
106
+ # Subset fonts are named with six uppercase letters: ABCDEF+OpenSans-Regular.
107
+ def subset_tag(gids)
108
+ Digest::MD5.digest(gids.pack("n*") + @ttf.postscript_name).bytes.first(6).map { |b| (65 + (b % 26)).chr }.join
109
+ end
110
+
111
+ # Text uses the original glyph ids as CIDs; this maps them to the subset's ids.
112
+ def cid_to_gid_map(gids, mapping)
113
+ map = Array.new((gids.max || 0) + 1, 0)
114
+ gids.each { |gid| map[gid] = mapping.fetch(gid) }
115
+ map.pack("n*")
116
+ end
117
+
118
+ def glyph_widths(gids)
119
+ gids.slice_when { |a, b| b != a + 1 }.flat_map do |run|
120
+ [run.first, run.map { |gid| glyph_space(@ttf.advance(gid)) }]
121
+ end
122
+ end
123
+
124
+ def to_unicode_cmap
125
+ mappings = @used.sort.map do |gid, char|
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
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # A document's font families and the Font objects it has drawn with. Each
6
+ # document gets its own book so glyph usage (and so subsetting) is per
7
+ # document, while the parsed TrueType data is shared through the Registry.
8
+ class FontBook
9
+ attr_reader :families
10
+
11
+ def initialize(families = {})
12
+ @families = families.dup
13
+ @fonts = {}
14
+ end
15
+
16
+ def inspect = "#<#{self.class} families=#{@families.keys.inspect}>"
17
+
18
+ def register(name, **paths)
19
+ @families[name.to_s] = Family.new(name, **paths)
20
+ end
21
+
22
+ # Returns [Font, Family::Face] for a Text::Style.
23
+ def resolve(style)
24
+ face = family(style.family).face(weight: style.weight, style: style.style)
25
+ [@fonts[face.path] ||= Font.new(Registry.load(face.path)), face]
26
+ end
27
+
28
+ private
29
+
30
+ def family(name)
31
+ @families[name.to_s] || @families.values.first ||
32
+ raise(Error, "no font registered; declare one with `font_family \"Name\", regular: \"path/to/font.ttf\"`")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # Reads the PostScript name (name id 6) used as the embedded font's BaseFont.
6
+ module NameTable
7
+ FORBIDDEN = "[](){}<>/%#"
8
+
9
+ module_function
10
+
11
+ def postscript_name(ttf)
12
+ name = find(ttf) if ttf.table?("name")
13
+ name.nil? || name.empty? ? "Font" : name
14
+ end
15
+
16
+ def find(ttf)
17
+ t = ttf.table_offset("name")
18
+ strings = t + ttf.u16(t + 4)
19
+ ttf.u16(t + 2).times do |i|
20
+ record = t + 6 + (i * 12)
21
+ next unless ttf.u16(record + 6) == 6
22
+
23
+ raw = ttf.data.byteslice(strings + ttf.u16(record + 10), ttf.u16(record + 8)).dup
24
+ name = decode(raw, ttf.u16(record)).delete("^!-~").delete(FORBIDDEN)
25
+ return name unless name.empty?
26
+ end
27
+ nil
28
+ end
29
+
30
+ def decode(raw, platform)
31
+ if platform == 1
32
+ raw.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
33
+ else
34
+ raw.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
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.
8
+ module Registry
9
+ SIZE = 16
10
+ @cache = {}
11
+ @mutex = Mutex.new
12
+
13
+ class << self
14
+ def load(path)
15
+ path = File.expand_path(path.to_s)
16
+ raise UnsupportedFont, "font file not found: #{path}" unless File.file?(path)
17
+
18
+ mtime = File.mtime(path)
19
+ @mutex.synchronize { fetch(path, mtime) }
20
+ end
21
+
22
+ def clear
23
+ @mutex.synchronize { @cache.clear }
24
+ end
25
+
26
+ private
27
+
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]
32
+ @cache.shift while @cache.size > SIZE
33
+ ttf
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end