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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +28 -0
- data/README.md +145 -0
- data/lib/stationery/builder.rb +61 -0
- data/lib/stationery/canvas/path.rb +82 -0
- data/lib/stationery/canvas/text.rb +55 -0
- data/lib/stationery/canvas.rb +121 -0
- data/lib/stationery/color.rb +62 -0
- data/lib/stationery/component.rb +54 -0
- data/lib/stationery/document.rb +95 -0
- data/lib/stationery/elements.rb +90 -0
- data/lib/stationery/errors.rb +11 -0
- data/lib/stationery/fonts/cmap.rb +75 -0
- data/lib/stationery/fonts/family.rb +51 -0
- data/lib/stationery/fonts/font.rb +149 -0
- data/lib/stationery/fonts/font_book.rb +36 -0
- data/lib/stationery/fonts/name_table.rb +39 -0
- data/lib/stationery/fonts/registry.rb +38 -0
- data/lib/stationery/fonts/subset.rb +139 -0
- data/lib/stationery/fonts/true_type.rb +174 -0
- data/lib/stationery/geometry.rb +56 -0
- data/lib/stationery/images/cache.rb +32 -0
- data/lib/stationery/images/images.rb +45 -0
- data/lib/stationery/images/jpeg.rb +53 -0
- data/lib/stationery/images/png.rb +119 -0
- data/lib/stationery/images/scanlines.rb +56 -0
- data/lib/stationery/layout/box.rb +134 -0
- data/lib/stationery/layout/flow.rb +121 -0
- data/lib/stationery/layout/image.rb +53 -0
- data/lib/stationery/layout/leaves.rb +58 -0
- data/lib/stationery/layout/node.rb +46 -0
- data/lib/stationery/layout/paginator.rb +41 -0
- data/lib/stationery/layout/positioned.rb +24 -0
- data/lib/stationery/layout/row.rb +71 -0
- data/lib/stationery/layout/table/cell.rb +93 -0
- data/lib/stationery/layout/table/selection.rb +52 -0
- data/lib/stationery/layout/table/widths.rb +53 -0
- data/lib/stationery/layout/table.rb +118 -0
- data/lib/stationery/layout/text.rb +62 -0
- data/lib/stationery/page.rb +47 -0
- data/lib/stationery/page_templates.rb +36 -0
- data/lib/stationery/pdf/assembler.rb +53 -0
- data/lib/stationery/pdf/serializer.rb +56 -0
- data/lib/stationery/pdf/stream.rb +27 -0
- data/lib/stationery/pdf/types.rb +15 -0
- data/lib/stationery/pdf/writer.rb +59 -0
- data/lib/stationery/rails.rb +20 -0
- data/lib/stationery/resources.rb +34 -0
- data/lib/stationery/text/entities.rb +23 -0
- data/lib/stationery/text/line.rb +26 -0
- data/lib/stationery/text/markup.rb +103 -0
- data/lib/stationery/text/paragraph.rb +98 -0
- data/lib/stationery/text/runs_builder.rb +52 -0
- data/lib/stationery/text/style.rb +46 -0
- data/lib/stationery/text/wrapper.rb +131 -0
- data/lib/stationery/version.rb +5 -0
- data/lib/stationery.rb +58 -0
- 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
|