stationery 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -1
- data/README.md +358 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +25 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +8 -5
- data/lib/stationery/canvas.rb +25 -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 +53 -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 +52 -88
- data/lib/stationery/fonts/font_book.rb +46 -8
- data/lib/stationery/fonts/glyph_run.rb +38 -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 +62 -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/packs.rb +45 -0
- 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 +32 -49
- 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 +85 -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 +45 -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 +5 -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 +18 -7
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +5 -1
- data/lib/stationery/pdf/writer.rb +4 -0
- 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/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/document.rb +73 -0
- data/lib/stationery/svg/parser.rb +38 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +68 -0
- data/lib/stationery/svg/transform.rb +43 -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 +32 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +2 -2
- data/lib/stationery/text/wrapper.rb +3 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +51 -0
- metadata +85 -4
|
@@ -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:)
|
|
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
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Rewrites a CFF font so every glyph a document did not draw is a bare
|
|
6
|
+
# `endchar`. Glyph ids, the charset, FDSelect, the Private DICTs and all
|
|
7
|
+
# Subrs are kept byte for byte, so CIDs and hinting are unchanged; only the
|
|
8
|
+
# CharStrings INDEX shrinks. Offsets in the Top DICT and the Font DICTs are
|
|
9
|
+
# written as fixed five-byte integers so their size does not depend on the
|
|
10
|
+
# layout they describe. Charstrings are not interpreted: a deprecated
|
|
11
|
+
# `seac` accent in a drawn glyph would lose its base and accent glyphs.
|
|
12
|
+
module CffSubset
|
|
13
|
+
ENDCHAR = "\x0E".b
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Header, Name INDEX, Top DICT INDEX, String and Global Subr INDEXes,
|
|
18
|
+
# then the blocks the Top DICT points at.
|
|
19
|
+
def build(cff, gids)
|
|
20
|
+
blocks = blocks(cff, gids)
|
|
21
|
+
head = cff.data.byteslice(0, cff.name_index.stop)
|
|
22
|
+
globals = cff.data.byteslice(cff.string_index.start, cff.global_subrs.stop - cff.string_index.start)
|
|
23
|
+
pos = head.bytesize + index([top_dict(cff, blocks.transform_values { 0 })]).bytesize + globals.bytesize
|
|
24
|
+
offsets = blocks.transform_values { |block| pos.tap { pos += block.bytesize } }
|
|
25
|
+
blocks[:fd_array] = fd_array(cff, offsets) if cff.cid_keyed?
|
|
26
|
+
|
|
27
|
+
[head, index([top_dict(cff, offsets)]), globals, *blocks.values].join
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The FDArray is sized here with placeholder offsets; build fills them in.
|
|
31
|
+
def blocks(cff, gids)
|
|
32
|
+
keep = Set.new(gids) << 0
|
|
33
|
+
charstrings = Array.new(cff.num_glyphs) { |gid| keep.include?(gid) ? cff.item(cff.charstrings, gid) : ENDCHAR }
|
|
34
|
+
blocks = tables(cff).merge(charstrings: index(charstrings))
|
|
35
|
+
privates(cff).each_with_index { |region, i| blocks[[:private, i]] = region }
|
|
36
|
+
blocks[:fd_array] = fd_array(cff, Hash.new(0)) if cff.cid_keyed?
|
|
37
|
+
blocks
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Charset, custom Encoding and FDSelect, copied as they are.
|
|
41
|
+
def tables(cff)
|
|
42
|
+
data = cff.data
|
|
43
|
+
top = cff.top
|
|
44
|
+
blocks = {}
|
|
45
|
+
if (offset = top.fetch(CFF::CHARSET, [0]).first) > 2
|
|
46
|
+
blocks[:charset] = data.byteslice(offset, charset_size(data, offset, cff.num_glyphs))
|
|
47
|
+
end
|
|
48
|
+
if (offset = top.fetch(CFF::ENCODING, [0]).first) > 1
|
|
49
|
+
blocks[:encoding] = data.byteslice(offset, encoding_size(data, offset))
|
|
50
|
+
end
|
|
51
|
+
if (offset = top[CFF::FD_SELECT]&.first)
|
|
52
|
+
blocks[:fd_select] = data.byteslice(offset, fd_select_size(data, offset, cff.num_glyphs))
|
|
53
|
+
end
|
|
54
|
+
blocks
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Each Private DICT with its local Subrs, as one region so the Subrs
|
|
58
|
+
# offset (relative to the Private DICT) still holds.
|
|
59
|
+
def privates(cff)
|
|
60
|
+
font_dicts(cff).map do |dict|
|
|
61
|
+
size, offset = dict.fetch(CFF::PRIVATE)
|
|
62
|
+
stop = offset + size
|
|
63
|
+
if (subrs = CFF.parse_dict(cff.data.byteslice(offset, size))[CFF::SUBRS])
|
|
64
|
+
stop = [stop, cff.index_at(offset + subrs.first).stop].max
|
|
65
|
+
end
|
|
66
|
+
cff.data.byteslice(offset, stop - offset)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def font_dicts(cff)
|
|
71
|
+
return [cff.top] unless cff.cid_keyed?
|
|
72
|
+
|
|
73
|
+
fd_array = cff.index_at(cff.top.fetch(CFF::FD_ARRAY).first)
|
|
74
|
+
fd_array.items.each_index.map { |i| CFF.parse_dict(cff.item(fd_array, i)) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def top_dict(cff, offsets)
|
|
78
|
+
encode(CFF.dict_entries(cff.item(cff.top_index, 0))) do |op, operands|
|
|
79
|
+
case op
|
|
80
|
+
when CFF::CHARSET then offsets.key?(:charset) ? [offsets[:charset]] : operands
|
|
81
|
+
when CFF::ENCODING then offsets.key?(:encoding) ? [offsets[:encoding]] : operands
|
|
82
|
+
when CFF::CHARSTRINGS then [offsets[:charstrings]]
|
|
83
|
+
when CFF::FD_SELECT then [offsets[:fd_select]]
|
|
84
|
+
when CFF::FD_ARRAY then [offsets[:fd_array]]
|
|
85
|
+
when CFF::PRIVATE then [operands.first, offsets[[:private, 0]]]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fd_array(cff, offsets)
|
|
91
|
+
fd_array = cff.index_at(cff.top.fetch(CFF::FD_ARRAY).first)
|
|
92
|
+
dicts = fd_array.items.each_index.map do |i|
|
|
93
|
+
encode(CFF.dict_entries(cff.item(fd_array, i))) do |op, operands|
|
|
94
|
+
[operands.first, offsets[[:private, i]]] if op == CFF::PRIVATE
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
index(dicts)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Entries the block answers are rewritten with five-byte integers; the
|
|
101
|
+
# rest keep their original bytes.
|
|
102
|
+
def encode(entries)
|
|
103
|
+
entries.map do |op, operands, raw|
|
|
104
|
+
values = yield(op, operands)
|
|
105
|
+
next raw unless values
|
|
106
|
+
|
|
107
|
+
values.map { |value| [29, value].pack("Cl>") }.join + (op >= 1200 ? [12, op - 1200].pack("CC") : op.chr)
|
|
108
|
+
end.join.b
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def index(items)
|
|
112
|
+
offsets = items.each_with_object([1]) { |item, acc| acc << (acc.last + item.bytesize) }
|
|
113
|
+
size = [1, 2, 3, 4].find { |bytes| offsets.last < 256**bytes }
|
|
114
|
+
[items.size, size].pack("nC") + offsets.map { |offset| [offset].pack("N").byteslice(4 - size, size) }.join +
|
|
115
|
+
items.join
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def charset_size(data, offset, num_glyphs)
|
|
119
|
+
format = data.getbyte(offset)
|
|
120
|
+
return 1 + ((num_glyphs - 1) * 2) if format.zero?
|
|
121
|
+
|
|
122
|
+
range = format == 1 ? 3 : 4
|
|
123
|
+
pos = offset + 1
|
|
124
|
+
covered = 1
|
|
125
|
+
while covered < num_glyphs
|
|
126
|
+
covered += 1 + (format == 1 ? data.getbyte(pos + 2) : data.byteslice(pos + 2, 2).unpack1("n"))
|
|
127
|
+
pos += range
|
|
128
|
+
end
|
|
129
|
+
pos - offset
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def encoding_size(data, offset)
|
|
133
|
+
format = data.getbyte(offset)
|
|
134
|
+
count = data.getbyte(offset + 1)
|
|
135
|
+
size = 2 + (format.nobits?(0x7F) ? count : count * 2)
|
|
136
|
+
size += 1 + (data.getbyte(offset + size) * 3) if format.allbits?(0x80)
|
|
137
|
+
size
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def fd_select_size(data, offset, num_glyphs)
|
|
141
|
+
data.getbyte(offset).zero? ? 1 + num_glyphs : 5 + (data.byteslice(offset + 1, 2).unpack1("n") * 3)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|