stationery 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -1
- data/README.md +395 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +26 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +10 -6
- data/lib/stationery/canvas.rb +38 -8
- data/lib/stationery/cli/fonts.rb +85 -0
- data/lib/stationery/cli/render.rb +137 -0
- data/lib/stationery/cli.rb +74 -0
- data/lib/stationery/document.rb +60 -9
- data/lib/stationery/elements/lists.rb +58 -0
- data/lib/stationery/elements/rich.rb +27 -0
- data/lib/stationery/elements.rb +88 -12
- data/lib/stationery/errors.rb +13 -1
- data/lib/stationery/fonts/bundled.rb +25 -0
- data/lib/stationery/fonts/catalog.rb +101 -0
- data/lib/stationery/fonts/cff.rb +140 -0
- data/lib/stationery/fonts/cff_subset.rb +145 -0
- data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
- data/lib/stationery/fonts/data/OFL.txt +92 -0
- data/lib/stationery/fonts/data/README.md +14 -0
- data/lib/stationery/fonts/embedding/base.rb +56 -0
- data/lib/stationery/fonts/embedding/cff.rb +35 -0
- data/lib/stationery/fonts/embedding/true_type.rb +35 -0
- data/lib/stationery/fonts/fallback.rb +58 -0
- data/lib/stationery/fonts/family.rb +11 -0
- data/lib/stationery/fonts/font.rb +68 -88
- data/lib/stationery/fonts/font_book.rb +49 -8
- data/lib/stationery/fonts/glyph_run.rb +39 -0
- data/lib/stationery/fonts/gpos/class_def.rb +25 -0
- data/lib/stationery/fonts/gpos/coverage.rb +20 -0
- data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
- data/lib/stationery/fonts/gpos.rb +64 -0
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/installer.rb +121 -0
- data/lib/stationery/fonts/kern_table.rb +52 -0
- data/lib/stationery/fonts/kerning.rb +17 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
- data/lib/stationery/fonts/to_unicode.rb +36 -0
- data/lib/stationery/fonts/true_type.rb +64 -57
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/html/document.rb +11 -0
- data/lib/stationery/html/tokenizer.rb +74 -0
- data/lib/stationery/html/tree_builder/blocks.rb +137 -0
- data/lib/stationery/html/tree_builder/collector.rb +50 -0
- data/lib/stationery/html/tree_builder.rb +72 -0
- data/lib/stationery/html/whitespace.rb +43 -0
- data/lib/stationery/layout/box.rb +102 -16
- data/lib/stationery/layout/flow.rb +48 -15
- data/lib/stationery/layout/image.rb +1 -0
- data/lib/stationery/layout/list_item.rb +89 -0
- data/lib/stationery/layout/mark.rb +58 -0
- data/lib/stationery/layout/node.rb +26 -2
- data/lib/stationery/layout/paginator.rb +35 -6
- data/lib/stationery/layout/positioned.rb +3 -1
- data/lib/stationery/layout/row.rb +34 -4
- data/lib/stationery/layout/svg.rb +48 -0
- data/lib/stationery/layout/table/cell.rb +33 -6
- data/lib/stationery/layout/table/grid.rb +93 -0
- data/lib/stationery/layout/table/row_splitter.rb +36 -0
- data/lib/stationery/layout/table/selection.rb +3 -2
- data/lib/stationery/layout/table.rb +69 -23
- data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
- data/lib/stationery/layout/table_of_contents.rb +43 -0
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/layout/wrap.rb +78 -0
- data/lib/stationery/list_markers.rb +47 -0
- data/lib/stationery/markdown/block_parser/containers.rb +94 -0
- data/lib/stationery/markdown/block_parser/tables.rb +54 -0
- data/lib/stationery/markdown/block_parser.rb +141 -0
- data/lib/stationery/markdown/document.rb +50 -0
- data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
- data/lib/stationery/markdown/inline_parser/links.rb +70 -0
- data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
- data/lib/stationery/markdown/inline_parser.rb +89 -0
- data/lib/stationery/minitest.rb +33 -0
- data/lib/stationery/outline.rb +37 -0
- data/lib/stationery/page.rb +18 -4
- data/lib/stationery/page_templates.rb +33 -10
- data/lib/stationery/pdf/assembler.rb +20 -8
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +22 -10
- data/lib/stationery/pdf/writer.rb +28 -5
- data/lib/stationery/preview.rb +64 -0
- data/lib/stationery/rails/previews_controller.rb +61 -0
- data/lib/stationery/rails.rb +2 -0
- data/lib/stationery/railtie.rb +60 -0
- data/lib/stationery/regions.rb +67 -0
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/nodes.rb +55 -0
- data/lib/stationery/rich/renderer/inlines.rb +53 -0
- data/lib/stationery/rich/renderer.rb +121 -0
- data/lib/stationery/rich/styles.rb +34 -0
- data/lib/stationery/rspec.rb +6 -0
- data/lib/stationery/structure.rb +78 -0
- data/lib/stationery/svg/arc.rb +91 -0
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +103 -0
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +58 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +85 -0
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +54 -0
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +123 -0
- data/lib/stationery/text/entities/html4.rb +49 -0
- data/lib/stationery/text/entities.rb +6 -1
- data/lib/stationery/text/line.rb +8 -2
- data/lib/stationery/text/paragraph.rb +33 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +4 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +67 -0
- metadata +101 -4
data/lib/stationery/document.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Stationery
|
|
|
5
5
|
#
|
|
6
6
|
# class Invoice < Stationery::Document
|
|
7
7
|
# page size: :a4, margin: 40
|
|
8
|
-
# font_family "
|
|
8
|
+
# font_family "Brand", regular: "Brand-Regular.ttf", bold: "Brand-Bold.ttf"
|
|
9
9
|
# default_text font: "Inter", size: 9
|
|
10
10
|
# metadata title: "Invoice"
|
|
11
11
|
# page_template { |page| box(at: [40, page.height - 30]) { text "#{page.number}/#{page.count}" } }
|
|
@@ -23,7 +23,8 @@ module Stationery
|
|
|
23
23
|
@config ||= if superclass.respond_to?(:config)
|
|
24
24
|
superclass.config.transform_values(&:dup)
|
|
25
25
|
else
|
|
26
|
-
{ page: { size: :letter, margin: 36 }, families: {}, text: {}, metadata: {},
|
|
26
|
+
{ page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
|
|
27
|
+
templates: [], regions: [], strict: false }
|
|
27
28
|
end
|
|
28
29
|
end
|
|
29
30
|
|
|
@@ -32,7 +33,13 @@ module Stationery
|
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def font_family(name, **paths)
|
|
35
|
-
config[:families][name.to_s] = Fonts::Family.
|
|
36
|
+
config[:families][name.to_s] = Fonts::Family.build(name, **paths)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Families tried, in order, for characters the text's own family has no
|
|
40
|
+
# glyph for; bundled Inter is tried last.
|
|
41
|
+
def font_fallbacks(*names)
|
|
42
|
+
config[:fallbacks] = names.map(&:to_s)
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
def default_text(**options)
|
|
@@ -43,11 +50,34 @@ module Stationery
|
|
|
43
50
|
config[:metadata] = config[:metadata].merge(info)
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
# Raise WarningsError instead of writing a PDF that produced warnings.
|
|
54
|
+
def strict(value = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
55
|
+
config[:strict] = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Encrypts every render with the standard security handler; see
|
|
59
|
+
# PDF::Encryption::StandardSecurity for the options.
|
|
60
|
+
def encrypt(**)
|
|
61
|
+
config[:encrypt] = PDF::Encryption::StandardSecurity.options(**)
|
|
62
|
+
end
|
|
63
|
+
|
|
46
64
|
# Runs after pagination on every page. `layer: :background` paints under
|
|
47
65
|
# the page's content.
|
|
48
66
|
def page_template(layer: :foreground, &block)
|
|
49
67
|
config[:templates] << [layer, block]
|
|
50
68
|
end
|
|
69
|
+
|
|
70
|
+
# Reserves space at the top of the pages `on:` matches and draws the
|
|
71
|
+
# block there. Without `height:` the block is measured once, on the
|
|
72
|
+
# first page that asks; pass `height:` when its content varies per page.
|
|
73
|
+
def header(height: nil, gap: 8, on: :all, &block)
|
|
74
|
+
config[:regions] << Region.new(slot: :header, height:, gap:, on: Regions.validate!(on), block:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Like header, at the bottom of the page; the block is bottom-aligned.
|
|
78
|
+
def footer(height: nil, gap: 8, on: :all, &block)
|
|
79
|
+
config[:regions] << Region.new(slot: :footer, height:, gap:, on: Regions.validate!(on), block:)
|
|
80
|
+
end
|
|
51
81
|
end
|
|
52
82
|
|
|
53
83
|
attr_reader :warnings
|
|
@@ -55,15 +85,21 @@ module Stationery
|
|
|
55
85
|
def page_options = self.class.config[:page]
|
|
56
86
|
def metadata = self.class.config[:metadata]
|
|
57
87
|
|
|
58
|
-
def to_pdf(target = nil)
|
|
59
|
-
|
|
88
|
+
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt])
|
|
89
|
+
encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
|
|
90
|
+
warnings = Warnings.new
|
|
91
|
+
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
|
|
60
92
|
call(builder = Builder.new(book:, text: self.class.config[:text]))
|
|
61
93
|
resources = Resources.new
|
|
62
|
-
|
|
94
|
+
regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
|
|
95
|
+
paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:)
|
|
63
96
|
pages = paginator.paginate(builder.root)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
97
|
+
PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:).apply(pages)
|
|
98
|
+
outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:))
|
|
99
|
+
@warnings = warnings
|
|
100
|
+
raise WarningsError, warnings if strict && warnings.any?
|
|
101
|
+
|
|
102
|
+
write(PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:).render, target)
|
|
67
103
|
end
|
|
68
104
|
|
|
69
105
|
# Used by page templates to build nodes into their own root.
|
|
@@ -75,8 +111,23 @@ module Stationery
|
|
|
75
111
|
@_builder = previous
|
|
76
112
|
end
|
|
77
113
|
|
|
114
|
+
# Builds a page template or region block into a fresh root node.
|
|
115
|
+
def template_root(info, book:, &)
|
|
116
|
+
builder = Builder.new(book:, text: self.class.config[:text])
|
|
117
|
+
build_with(builder) { instance_exec(info, &) }
|
|
118
|
+
builder.root
|
|
119
|
+
end
|
|
120
|
+
|
|
78
121
|
private
|
|
79
122
|
|
|
123
|
+
def region_measure(book)
|
|
124
|
+
page = Page.new(**page_options)
|
|
125
|
+
lambda do |region, number|
|
|
126
|
+
info = PageInfo.new(number, number, page.width, page.height, page.margin, page.margin_box)
|
|
127
|
+
template_root(info, book:, ®ion.block).measure(page.margin_box.width)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
80
131
|
def info
|
|
81
132
|
metadata.to_h do |key, value|
|
|
82
133
|
[INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Bulleted and numbered lists.
|
|
5
|
+
module Elements
|
|
6
|
+
BULLETS = %i[disc circle square].freeze
|
|
7
|
+
|
|
8
|
+
# A bulleted list. `style:` is :disc, :circle, :square, :dash or any
|
|
9
|
+
# String; unstyled nested lists cycle disc → circle → square. Every node
|
|
10
|
+
# added inside becomes an item, `li` or not.
|
|
11
|
+
def ul(style: nil, gap: 4, indent: nil, marker_gap: 6, marker_color: nil, &)
|
|
12
|
+
shape = style || BULLETS[@_builder.list_depth % BULLETS.size]
|
|
13
|
+
marker_style = @_builder.style({ color: marker_color }.compact)
|
|
14
|
+
list(marker_style, gap:, indent:, marker_gap:, marker: ->(_) { list_bullet(shape, marker_style) }, &)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A numbered list. `format:` is :decimal, :alpha, :upper_alpha, :roman,
|
|
18
|
+
# :upper_roman or a Proc given the number.
|
|
19
|
+
def ol(format: :decimal, start: 1, suffix: ".", gap: 4, indent: nil, marker_gap: 6, marker_color: nil, &)
|
|
20
|
+
marker_style = @_builder.style({ color: marker_color }.compact)
|
|
21
|
+
label = ->(index) { list_label(ListMarkers.label(format, start + index, suffix), marker_style) }
|
|
22
|
+
list(marker_style, gap:, indent:, marker_gap:, marker: label, &)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A list item: a String becomes a paragraph (taking every text option),
|
|
26
|
+
# a block a flow of its children.
|
|
27
|
+
def li(content = nil, gap: 0, **text_options, &)
|
|
28
|
+
return text(content, **text_options) if content
|
|
29
|
+
|
|
30
|
+
@_builder.add(container(gap:, &))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def list(marker_style, gap:, indent:, marker_gap:, marker:, &)
|
|
36
|
+
items = Builder::Items.new
|
|
37
|
+
@_builder.nested_list { @_builder.within(items) { yield_content(&) } }
|
|
38
|
+
markers = items.nodes.each_index.map(&marker)
|
|
39
|
+
indent ||= [markers.map(&:natural_width).max || 0, marker_style.size].max + marker_gap
|
|
40
|
+
entries = items.nodes.zip(markers).map do |node, mark|
|
|
41
|
+
Layout::ListItem.new(mark, node.is_a?(Layout::Flow) ? node : Layout::Flow.new([node]), indent:, marker_gap:)
|
|
42
|
+
end
|
|
43
|
+
@_builder.add(Layout::Flow.new(entries, gap:))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def list_bullet(shape, style)
|
|
47
|
+
return Layout::Bullet.new(shape, style:, context: @_builder.context(style)) if BULLETS.include?(shape)
|
|
48
|
+
return list_label(shape.to_s, style) unless shape == :dash
|
|
49
|
+
|
|
50
|
+
font = @_builder.book.resolve(style).first
|
|
51
|
+
list_label(font.glyph?("–") ? "–" : "-", style)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def list_label(label, style)
|
|
55
|
+
Layout::Text.new([Text::Run.new(label, style)], context: @_builder.context(style), align: :right)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# HTML and Markdown rendered with the element DSL; the parsers load on first use.
|
|
5
|
+
module Elements
|
|
6
|
+
# HTML such as ActionText's `record.body.to_s`. `images:` resolves an
|
|
7
|
+
# image src to a path or IO (nil skips it); otherwise it is read from
|
|
8
|
+
# under `base_path:`. Remote images are never fetched.
|
|
9
|
+
def html(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false)
|
|
10
|
+
require_relative "../html/document"
|
|
11
|
+
rich(HTML.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# CommonMark (plus GFM tables and strikethrough); options as for html.
|
|
15
|
+
def markdown(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false)
|
|
16
|
+
require_relative "../markdown/document"
|
|
17
|
+
rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def rich(blocks, **)
|
|
23
|
+
require_relative "../rich/renderer"
|
|
24
|
+
Rich::Renderer.new(self, @_builder, **).render(blocks)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -7,7 +7,8 @@ module Stationery
|
|
|
7
7
|
|
|
8
8
|
# A paragraph. Plain strings are always literal; pass `markup: true` to
|
|
9
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,
|
|
10
|
+
def text(content = nil, markup: false, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
|
|
11
|
+
**options, &)
|
|
11
12
|
settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading)).merge(options)
|
|
12
13
|
style = @_builder.style(options)
|
|
13
14
|
runs = text_runs(content, style, markup, &)
|
|
@@ -15,40 +16,83 @@ module Stationery
|
|
|
15
16
|
leading: settings[:leading])
|
|
16
17
|
node.keep_with_next = keep_with_next
|
|
17
18
|
node.break_inside = break_inside
|
|
18
|
-
@_builder.add(node)
|
|
19
|
+
@_builder.add(mark(node, anchor, bookmark))
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
# A container with padding, background, border and radius. `at: [x, y]`
|
|
22
23
|
# places it at a fixed page position outside the flow.
|
|
23
|
-
|
|
24
|
+
# `break_inside: :auto` splits it at any page break, `:avoid` never; by
|
|
25
|
+
# default it splits only when it does not fit on a page of its own.
|
|
26
|
+
def box(at: nil, align: nil, gap: 0, width: nil, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
|
|
27
|
+
**, &)
|
|
24
28
|
node = Layout::Box.new(container(align:, gap:, &), width:, **)
|
|
25
29
|
node.keep_with_next = keep_with_next
|
|
30
|
+
node.break_inside = break_inside
|
|
31
|
+
node = mark(node, anchor, bookmark)
|
|
26
32
|
@_builder.add(at ? Layout::Positioned.new(node, x: at[0], y: at[1], width:) : node)
|
|
27
33
|
end
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
# Columns side by side; splits across pages like a box (`break_inside:`).
|
|
36
|
+
def row(gap: 0, align: :top, break_inside: nil)
|
|
30
37
|
columns = Builder::Columns.new
|
|
31
38
|
@_builder.within(columns) { yield if block_given? }
|
|
32
|
-
|
|
39
|
+
node = Layout::Row.new(columns.nodes, gap:, align:)
|
|
40
|
+
node.break_inside = break_inside
|
|
41
|
+
@_builder.add(node)
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
# A row column: `width:` in points, as a fraction (0.5), :auto or nil for
|
|
36
45
|
# an equal share. Takes every box option.
|
|
37
|
-
def column(width: nil, align: nil, gap: 0, **, &)
|
|
38
|
-
|
|
46
|
+
def column(width: nil, align: nil, gap: 0, break_inside: nil, **, &)
|
|
47
|
+
node = Layout::Box.new(container(align:, gap:, &), width:, **)
|
|
48
|
+
node.break_inside = break_inside
|
|
49
|
+
@_builder.add(node)
|
|
39
50
|
end
|
|
40
51
|
|
|
41
52
|
# Children kept in one vertical group; `keep_together: true` moves the
|
|
42
53
|
# 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:, &)
|
|
54
|
+
def group(gap: 0, align: nil, keep_together: false, keep_with_next: nil, anchor: nil, bookmark: nil, &)
|
|
55
|
+
flow = container(align:, gap:, &)
|
|
45
56
|
flow.break_inside = :avoid if keep_together
|
|
46
57
|
flow.keep_with_next = keep_with_next
|
|
47
|
-
@_builder.add(flow)
|
|
58
|
+
@_builder.add(mark(flow, anchor, bookmark))
|
|
48
59
|
end
|
|
49
60
|
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
# Children side by side at their own widths, wrapping onto new rows.
|
|
62
|
+
def wrap(gap: 0, row_gap: nil, align: :left, &)
|
|
63
|
+
flow = container(&)
|
|
64
|
+
@_builder.add(Layout::Wrap.new(flow.children, gap:, row_gap: row_gap || gap, align:))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# An SVG drawing: markup String, or a path to a .svg file. `currentColor`
|
|
68
|
+
# takes `color:`.
|
|
69
|
+
def svg(source, width: nil, height: nil, color: "#000000", align: nil)
|
|
70
|
+
name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
|
|
71
|
+
source = File.read(source.to_s) unless name == "inline"
|
|
72
|
+
document = SVG::Document.parse(source)
|
|
73
|
+
if document.unsupported.any?
|
|
74
|
+
@_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
|
|
75
|
+
end
|
|
76
|
+
node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context)
|
|
77
|
+
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Cells are strings, layout nodes, procs built with the DSL (`-> { image … }`)
|
|
81
|
+
# or components.
|
|
82
|
+
def table(rows, widths: nil, width: :auto, header: false, split_rows: false, cell: {}, anchor: nil, bookmark: nil,
|
|
83
|
+
&)
|
|
84
|
+
rows = rows.map { |row| row.map { |content| cell_content(content) } }
|
|
85
|
+
node = Layout::Table.new(rows, context: @_builder.context, widths:, width:, header:, split_rows:, cell:, &)
|
|
86
|
+
@_builder.add(mark(node, anchor, bookmark))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The document's bookmarks with their page numbers, one linked row each.
|
|
90
|
+
# `levels:` is a Range (or an Integer maximum depth); `leader:` is :dots,
|
|
91
|
+
# :line or nil; text options style the rows.
|
|
92
|
+
def table_of_contents(levels: 1.., leader: :dots, indent: 12, number_width: nil, gap: 4, **options)
|
|
93
|
+
node = Layout::TableOfContents.new(@_builder.outline, context: @_builder.context(@_builder.style(options)),
|
|
94
|
+
levels:, leader:, indent:, number_width:, gap:)
|
|
95
|
+
@_builder.add(node)
|
|
52
96
|
end
|
|
53
97
|
|
|
54
98
|
def image(source, align: nil, **)
|
|
@@ -60,6 +104,16 @@ module Stationery
|
|
|
60
104
|
def spacer(height) = @_builder.add(Layout::Spacer.new(height))
|
|
61
105
|
def page_break = @_builder.add(Layout::PageBreak.new)
|
|
62
106
|
|
|
107
|
+
# A named link target (`link: "#name"`) at this point; it moves to the
|
|
108
|
+
# next page with whatever follows it.
|
|
109
|
+
def anchor(name) = @_builder.add(Layout::Mark.standalone(name))
|
|
110
|
+
|
|
111
|
+
# An outline entry (PDF bookmark) at this point; like a standalone anchor
|
|
112
|
+
# it moves to the next page with whatever follows it.
|
|
113
|
+
def bookmark(title, level: 1, open: false)
|
|
114
|
+
@_builder.add(Layout::Mark.standalone(@_builder.outline.add(title, level:, open:).anchor))
|
|
115
|
+
end
|
|
116
|
+
|
|
63
117
|
# Draw directly: the block receives the canvas and the reserved rectangle.
|
|
64
118
|
def canvas(height:, at: nil, width: nil, &)
|
|
65
119
|
node = Layout::CanvasNode.new(height:, &)
|
|
@@ -73,6 +127,20 @@ module Stationery
|
|
|
73
127
|
|
|
74
128
|
private
|
|
75
129
|
|
|
130
|
+
# `bookmark:` is a title, or { title:, level:, open: }.
|
|
131
|
+
def mark(node, anchor, bookmark)
|
|
132
|
+
names = [anchor&.to_s]
|
|
133
|
+
names << outline_entry(bookmark).anchor if bookmark
|
|
134
|
+
names.compact!
|
|
135
|
+
names.empty? ? node : Layout::Mark.new(node, names)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def outline_entry(bookmark)
|
|
139
|
+
return @_builder.outline.add(bookmark) unless bookmark.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
@_builder.outline.add(bookmark.fetch(:title), **bookmark.slice(:level, :open))
|
|
142
|
+
end
|
|
143
|
+
|
|
76
144
|
def container(align: nil, gap: 0, &)
|
|
77
145
|
flow = Layout::Flow.new([], gap:, align: align || :left)
|
|
78
146
|
@_builder.within(flow) do
|
|
@@ -80,6 +148,14 @@ module Stationery
|
|
|
80
148
|
end
|
|
81
149
|
end
|
|
82
150
|
|
|
151
|
+
def cell_content(content)
|
|
152
|
+
case content
|
|
153
|
+
when Proc then container(&content)
|
|
154
|
+
when Component then container { render content }
|
|
155
|
+
else content
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
83
159
|
def text_runs(content, style, markup, &)
|
|
84
160
|
return Text::RunsBuilder.build(style, &) if block_given?
|
|
85
161
|
return Text::Markup.parse(content, style) if markup
|
data/lib/stationery/errors.rb
CHANGED
|
@@ -3,9 +3,21 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
|
-
# A font file stationery cannot read or embed (
|
|
6
|
+
# A font file stationery cannot read or embed (CFF2, collections, WOFF, …).
|
|
7
7
|
class UnsupportedFont < Error; end
|
|
8
8
|
|
|
9
9
|
# An image format stationery cannot embed (WebP, GIF, interlaced PNG, …).
|
|
10
10
|
class UnsupportedImage < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised by a strict render (`to_pdf(strict: true)`) that produced warnings.
|
|
13
|
+
class WarningsError < Error
|
|
14
|
+
attr_reader :warnings
|
|
15
|
+
|
|
16
|
+
def initialize(warnings)
|
|
17
|
+
@warnings = warnings
|
|
18
|
+
lines = warnings.map { |warning| " #{warning.message}" }
|
|
19
|
+
noun = lines.size == 1 ? "warning" : "warnings"
|
|
20
|
+
super("#{lines.size} #{noun}:\n#{lines.join("\n")}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
11
23
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Families shipped inside the gem. Only file names live here; a face is
|
|
6
|
+
# read the first time a document draws with it, so requiring the gem
|
|
7
|
+
# never touches the files.
|
|
8
|
+
module Bundled
|
|
9
|
+
DIR = File.expand_path("data", __dir__)
|
|
10
|
+
DEFAULT = "Inter"
|
|
11
|
+
FAMILIES = {
|
|
12
|
+
"Inter" => { regular: "Inter-Regular.ttf", bold: "Inter-Bold.ttf",
|
|
13
|
+
italic: "Inter-Italic.ttf", bold_italic: "Inter-BoldItalic.ttf" }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def self.names = FAMILIES.keys
|
|
17
|
+
|
|
18
|
+
# A Family for a bundled name, or nil.
|
|
19
|
+
def self.family(name)
|
|
20
|
+
files = FAMILIES[name.to_s]
|
|
21
|
+
files && Family.new(name, **files.transform_values { |file| File.join(DIR, file) })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Font packs `stationery fonts install` can put in vendor/fonts. Each file
|
|
6
|
+
# is [url, sha256, filename]; a URL with a `#member` fragment names a file
|
|
7
|
+
# inside a .tar.gz, and a nil URL copies the file bundled with the gem.
|
|
8
|
+
# `rake fonts:verify` downloads everything and checks the SHAs.
|
|
9
|
+
module Catalog
|
|
10
|
+
Pack = Data.define(:key, :family, :license, :files, :license_file)
|
|
11
|
+
|
|
12
|
+
NOTO = "https://raw.githubusercontent.com/notofonts/notofonts.github.io/" \
|
|
13
|
+
"f145d86c53996717bc4c25d4602eb9294e43dccc/fonts"
|
|
14
|
+
NOTO_OFL = ["https://raw.githubusercontent.com/notofonts/latin-greek-cyrillic/NotoSans-v2.015/OFL.txt",
|
|
15
|
+
"cee9892f9f0cc8fe882c9e9537ee6a89621d86ee7ceaf70b02e2b2b1c25c061a", "OFL.txt"].freeze
|
|
16
|
+
LIBERATION = "https://github.com/liberationfonts/liberation-fonts/files/7261482/" \
|
|
17
|
+
"liberation-fonts-ttf-2.1.5.tar.gz#liberation-fonts-ttf-2.1.5"
|
|
18
|
+
LIBERATION_OFL = ["#{LIBERATION}/LICENSE",
|
|
19
|
+
"93fed46019c38bbe566b479d22148e2e8a1e85ada614accb0211c37b2c61c19b", "OFL.txt"].freeze
|
|
20
|
+
LIBERATION_LICENSE = 'OFL-1.1, Reserved Font Name "Liberation"'
|
|
21
|
+
|
|
22
|
+
# { style => [url, sha, "Family-Style.ttf"] } with the URL built from the file name.
|
|
23
|
+
def self.faces(family, shas)
|
|
24
|
+
shas.to_h do |style, sha|
|
|
25
|
+
file = "#{family}-#{style.to_s.split("_").map(&:capitalize).join}.ttf"
|
|
26
|
+
[style, [yield(file), sha, file].freeze]
|
|
27
|
+
end.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.noto(family, shas) = faces(family, shas) { "#{NOTO}/#{family}/unhinted/ttf/#{it}" }
|
|
31
|
+
def self.liberation(family, shas) = faces(family, shas) { "#{LIBERATION}/#{it}" }
|
|
32
|
+
private_class_method :faces, :noto, :liberation
|
|
33
|
+
|
|
34
|
+
PACKS = [
|
|
35
|
+
Pack.new(
|
|
36
|
+
key: :inter, family: "Inter", license: "OFL-1.1",
|
|
37
|
+
files: {
|
|
38
|
+
regular: [nil, "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82", "Inter-Regular.ttf"],
|
|
39
|
+
bold: [nil, "288316099b1e0a47a4716d159098005eef7c0066921f34e3200393dbdb01947f", "Inter-Bold.ttf"],
|
|
40
|
+
italic: [nil, "bbc051dd204b5019a1aa0bc0ae2aa8a05ab13e7a3f979fa357631dc7feb6833a", "Inter-Italic.ttf"],
|
|
41
|
+
bold_italic: [nil, "948405a16cdc62701da5f4005ed068ca5f4d27061d98f7974ccfc37831d9581d",
|
|
42
|
+
"Inter-BoldItalic.ttf"]
|
|
43
|
+
},
|
|
44
|
+
license_file: [nil, "262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a", "OFL.txt"]
|
|
45
|
+
),
|
|
46
|
+
Pack.new(
|
|
47
|
+
key: :noto_sans, family: "Noto Sans", license: "OFL-1.1", license_file: NOTO_OFL,
|
|
48
|
+
files: noto("NotoSans",
|
|
49
|
+
regular: "f3961a9cde016d41a4879aecda1474d3a36d6bf54fa0e4643de029cc2248b0e8",
|
|
50
|
+
bold: "87cb2d84472a7d66da659ee47b6cdb9552326e8c128245231f191b6ac72529d9",
|
|
51
|
+
italic: "678288f868807d4d64a6f3b51466871d117d915780381ce9d0ed4b3bcbd06d37",
|
|
52
|
+
bold_italic: "3d367743f371f28671d2764e911a53d7c20ec9b6aa8791d059e7090389fc52a5")
|
|
53
|
+
),
|
|
54
|
+
Pack.new(
|
|
55
|
+
key: :noto_serif, family: "Noto Serif", license: "OFL-1.1", license_file: NOTO_OFL,
|
|
56
|
+
files: noto("NotoSerif",
|
|
57
|
+
regular: "a15cfbbc1539d707115111d672d590a3d70d4f74b4c0a315956da20ae19a14e1",
|
|
58
|
+
bold: "24ad531e6b05ddad8c3d89572d2c93eb86a6b74e652ce7ee3c3e171de68e84c3",
|
|
59
|
+
italic: "c4b3c971741ecdb40f5a443bce754e8fe91efe761b6ea10c92be7c3597cdadc4",
|
|
60
|
+
bold_italic: "1bc4f86502eaa368718f6192bee022ea9a703d5af1c18b7d291212657b63074a")
|
|
61
|
+
),
|
|
62
|
+
Pack.new(
|
|
63
|
+
key: :noto_sans_mono, family: "Noto Sans Mono", license: "OFL-1.1", license_file: NOTO_OFL,
|
|
64
|
+
files: noto("NotoSansMono",
|
|
65
|
+
regular: "87f8ce0522a6c99b743ee5fc75b4073cfdd575639119672828b7b9944b65b4f4",
|
|
66
|
+
bold: "1100772b2f79c102402a1011df5e2226517d0f40ac553a25fb12fdbad8c11b85")
|
|
67
|
+
),
|
|
68
|
+
Pack.new(
|
|
69
|
+
key: :liberation_sans, family: "Liberation Sans", license: LIBERATION_LICENSE, license_file: LIBERATION_OFL,
|
|
70
|
+
files: liberation("LiberationSans",
|
|
71
|
+
regular: "76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8",
|
|
72
|
+
bold: "788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173",
|
|
73
|
+
italic: "e5bae5c4cde31f22142753855f4f8fb86da6ff39955ed3c0a11248b0d16948b0",
|
|
74
|
+
bold_italic: "698da70fc191cc5f33ad4d6d3fe830fe4624b898ea2e3169955928b7c491f1ee")
|
|
75
|
+
),
|
|
76
|
+
Pack.new(
|
|
77
|
+
key: :liberation_serif, family: "Liberation Serif", license: LIBERATION_LICENSE,
|
|
78
|
+
license_file: LIBERATION_OFL,
|
|
79
|
+
files: liberation("LiberationSerif",
|
|
80
|
+
regular: "058ea80864aef09a23f45cbec2bb5400bc3dfbdea01c3f10538a21fcb497fb74",
|
|
81
|
+
bold: "d754ba427cfe0bca54ae052384baa8f842da5bd6550ad4da024ac441e7a7d5ce",
|
|
82
|
+
italic: "0e3dea9f8d613e006ccfa62201f33e265d19167bd0907725c3e145368b04fc2e",
|
|
83
|
+
bold_italic: "f17db8af71e24d2066b587546021d4f0b296be389512b658dec3c09affeb11a7")
|
|
84
|
+
),
|
|
85
|
+
Pack.new(
|
|
86
|
+
key: :liberation_mono, family: "Liberation Mono", license: LIBERATION_LICENSE, license_file: LIBERATION_OFL,
|
|
87
|
+
files: liberation("LiberationMono",
|
|
88
|
+
regular: "f2b83c763e8afd21709333370bed4774337fae82267937e2b5aea7e2fbd922c1",
|
|
89
|
+
bold: "bd62a0672d0b9b6710b01df434c80ad54fa5f0835207eb7b17b7a761463067bb",
|
|
90
|
+
italic: "605c01c711b44480a7508d349dfbf3264e81fa43d69e61cfa7d10b86e764c4d1",
|
|
91
|
+
bold_italic: "79451f3c09fe25116098853b7a2ca6e2436220ccc11af022979adbcf195be130")
|
|
92
|
+
)
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
def self.fetch(key)
|
|
96
|
+
PACKS.find { |pack| pack.key.to_s == key.to_s } ||
|
|
97
|
+
raise(Error, "unknown font pack #{key.to_s.inspect} (available: #{PACKS.map(&:key).join(", ")})")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Reads the Compact Font Format table of an OpenType font (.otf): the
|
|
6
|
+
# INDEX structures, the Top DICT and, for a CID-keyed font, the charset
|
|
7
|
+
# that maps glyph ids to CIDs. Charstrings are not interpreted.
|
|
8
|
+
class CFF
|
|
9
|
+
STANDARD_STRINGS = 391
|
|
10
|
+
CHARSET = 15
|
|
11
|
+
ENCODING = 16
|
|
12
|
+
CHARSTRINGS = 17
|
|
13
|
+
PRIVATE = 18
|
|
14
|
+
ROS = 1230
|
|
15
|
+
FD_ARRAY = 1236
|
|
16
|
+
FD_SELECT = 1237
|
|
17
|
+
SUBRS = 19
|
|
18
|
+
REAL_NIBBLES = [*"0".."9", ".", "E", "E-", nil, "-"].freeze
|
|
19
|
+
|
|
20
|
+
# An INDEX from `start` to `stop` (exclusive); items are [offset, length].
|
|
21
|
+
Index = Data.define(:start, :items, :stop)
|
|
22
|
+
|
|
23
|
+
attr_reader :data, :top, :num_glyphs, :name_index, :top_index, :string_index, :global_subrs, :charstrings
|
|
24
|
+
|
|
25
|
+
def initialize(data)
|
|
26
|
+
@data = data.b
|
|
27
|
+
@name_index = index_at(@data.getbyte(2))
|
|
28
|
+
@top_index = index_at(@name_index.stop)
|
|
29
|
+
@string_index = index_at(@top_index.stop)
|
|
30
|
+
@global_subrs = index_at(@string_index.stop)
|
|
31
|
+
@top = self.class.parse_dict(item(@top_index, 0))
|
|
32
|
+
@charstrings = index_at(@top.fetch(CHARSTRINGS).first)
|
|
33
|
+
@num_glyphs = @charstrings.items.size
|
|
34
|
+
@charset = self.class.parse_charset(@data, @top.fetch(CHARSET).first, @num_glyphs) if cid_keyed?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inspect = "#<#{self.class} glyphs=#{@num_glyphs}#{" cid" if cid_keyed?}>"
|
|
38
|
+
|
|
39
|
+
def cid_keyed? = @top.key?(ROS)
|
|
40
|
+
|
|
41
|
+
# [registry, ordering, supplement] of a CID-keyed font.
|
|
42
|
+
def ros
|
|
43
|
+
return unless cid_keyed?
|
|
44
|
+
|
|
45
|
+
registry, ordering, supplement = @top[ROS]
|
|
46
|
+
[string(registry), string(ordering), supplement]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A name-keyed font has no CIDs; PDF addresses its glyphs by glyph id.
|
|
50
|
+
def cid_for(gid)
|
|
51
|
+
cid_keyed? ? @charset.fetch(gid, 0) : gid
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def item(index, number) = @data.byteslice(*index.items.fetch(number))
|
|
55
|
+
|
|
56
|
+
def string(sid) = item(@string_index, sid - STANDARD_STRINGS)
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# { operator => operands }; two-byte operators are 1200 + the second byte.
|
|
60
|
+
def parse_dict(bytes) = dict_entries(bytes).to_h { |op, operands, _| [op, operands] }
|
|
61
|
+
|
|
62
|
+
# [operator, operands, raw bytes of operands and operator] in order.
|
|
63
|
+
def dict_entries(bytes)
|
|
64
|
+
entries = []
|
|
65
|
+
operands = []
|
|
66
|
+
start = pos = 0
|
|
67
|
+
while pos < bytes.bytesize
|
|
68
|
+
b0 = bytes.getbyte(pos)
|
|
69
|
+
if b0 <= 21
|
|
70
|
+
op = b0 == 12 ? 1200 + bytes.getbyte(pos + 1) : b0
|
|
71
|
+
pos += b0 == 12 ? 2 : 1
|
|
72
|
+
entries << [op, operands, bytes.byteslice(start, pos - start)]
|
|
73
|
+
operands = []
|
|
74
|
+
start = pos
|
|
75
|
+
else
|
|
76
|
+
value, pos = operand(bytes, pos)
|
|
77
|
+
operands << value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
entries
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The SID (name-keyed) or CID (CID-keyed) of every glyph, by glyph id.
|
|
84
|
+
def parse_charset(data, offset, num_glyphs)
|
|
85
|
+
format = data.getbyte(offset)
|
|
86
|
+
return [0, *data.byteslice(offset + 1, (num_glyphs - 1) * 2).unpack("n*")] if format.zero?
|
|
87
|
+
|
|
88
|
+
ids = [0]
|
|
89
|
+
pos = offset + 1
|
|
90
|
+
while ids.size < num_glyphs
|
|
91
|
+
first, left = data.byteslice(pos, format == 1 ? 3 : 4).unpack(format == 1 ? "nC" : "nn")
|
|
92
|
+
ids.concat((first..(first + left)).to_a)
|
|
93
|
+
pos += format == 1 ? 3 : 4
|
|
94
|
+
end
|
|
95
|
+
ids.first(num_glyphs)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def operand(bytes, pos)
|
|
101
|
+
b0 = bytes.getbyte(pos)
|
|
102
|
+
case b0
|
|
103
|
+
when 28 then [bytes.byteslice(pos + 1, 2).unpack1("s>"), pos + 3]
|
|
104
|
+
when 29 then [bytes.byteslice(pos + 1, 4).unpack1("l>"), pos + 5]
|
|
105
|
+
when 30 then real(bytes, pos + 1)
|
|
106
|
+
when 32..246 then [b0 - 139, pos + 1]
|
|
107
|
+
when 247..250 then [((b0 - 247) * 256) + bytes.getbyte(pos + 1) + 108, pos + 2]
|
|
108
|
+
when 251..254 then [((251 - b0) * 256) - bytes.getbyte(pos + 1) - 108, pos + 2]
|
|
109
|
+
else raise UnsupportedFont, "malformed CFF DICT operand #{b0}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def real(bytes, pos)
|
|
114
|
+
text = +""
|
|
115
|
+
loop do
|
|
116
|
+
byte = bytes.getbyte(pos)
|
|
117
|
+
pos += 1
|
|
118
|
+
[byte >> 4, byte & 0x0F].each do |nibble|
|
|
119
|
+
return [Float(text), pos] if nibble == 0x0F
|
|
120
|
+
|
|
121
|
+
text << REAL_NIBBLES.fetch(nibble)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def index_at(offset)
|
|
128
|
+
count = @data.byteslice(offset, 2).unpack1("n")
|
|
129
|
+
return Index.new(offset, [], offset + 2) if count.zero?
|
|
130
|
+
|
|
131
|
+
size = @data.getbyte(offset + 2)
|
|
132
|
+
offsets = Array.new(count + 1) do |i|
|
|
133
|
+
@data.byteslice(offset + 3 + (i * size), size).bytes.inject(0) { |value, byte| (value << 8) | byte }
|
|
134
|
+
end
|
|
135
|
+
base = offset + 2 + ((count + 1) * size)
|
|
136
|
+
Index.new(offset, offsets.each_cons(2).map { |a, b| [base + a, b - a] }, base + offsets.last)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|