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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +358 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +25 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +8 -5
  10. data/lib/stationery/canvas.rb +25 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +53 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +52 -88
  35. data/lib/stationery/fonts/font_book.rb +46 -8
  36. data/lib/stationery/fonts/glyph_run.rb +38 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +62 -0
  41. data/lib/stationery/fonts/installer.rb +121 -0
  42. data/lib/stationery/fonts/kern_table.rb +52 -0
  43. data/lib/stationery/fonts/kerning.rb +17 -0
  44. data/lib/stationery/fonts/packs.rb +45 -0
  45. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  46. data/lib/stationery/fonts/to_unicode.rb +36 -0
  47. data/lib/stationery/fonts/true_type.rb +32 -49
  48. data/lib/stationery/html/document.rb +11 -0
  49. data/lib/stationery/html/tokenizer.rb +74 -0
  50. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  51. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  52. data/lib/stationery/html/tree_builder.rb +72 -0
  53. data/lib/stationery/html/whitespace.rb +43 -0
  54. data/lib/stationery/layout/box.rb +85 -16
  55. data/lib/stationery/layout/flow.rb +48 -15
  56. data/lib/stationery/layout/image.rb +1 -0
  57. data/lib/stationery/layout/list_item.rb +89 -0
  58. data/lib/stationery/layout/mark.rb +58 -0
  59. data/lib/stationery/layout/node.rb +26 -2
  60. data/lib/stationery/layout/paginator.rb +35 -6
  61. data/lib/stationery/layout/positioned.rb +3 -1
  62. data/lib/stationery/layout/row.rb +34 -4
  63. data/lib/stationery/layout/svg.rb +45 -0
  64. data/lib/stationery/layout/table/cell.rb +33 -6
  65. data/lib/stationery/layout/table/grid.rb +93 -0
  66. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  67. data/lib/stationery/layout/table/selection.rb +3 -2
  68. data/lib/stationery/layout/table.rb +69 -23
  69. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  70. data/lib/stationery/layout/table_of_contents.rb +43 -0
  71. data/lib/stationery/layout/text.rb +5 -4
  72. data/lib/stationery/layout/wrap.rb +78 -0
  73. data/lib/stationery/list_markers.rb +47 -0
  74. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  75. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  76. data/lib/stationery/markdown/block_parser.rb +141 -0
  77. data/lib/stationery/markdown/document.rb +50 -0
  78. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  79. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  80. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  81. data/lib/stationery/markdown/inline_parser.rb +89 -0
  82. data/lib/stationery/minitest.rb +33 -0
  83. data/lib/stationery/outline.rb +37 -0
  84. data/lib/stationery/page.rb +18 -4
  85. data/lib/stationery/page_templates.rb +33 -10
  86. data/lib/stationery/pdf/assembler.rb +18 -7
  87. data/lib/stationery/pdf/outline_writer.rb +72 -0
  88. data/lib/stationery/pdf/serializer.rb +5 -1
  89. data/lib/stationery/pdf/writer.rb +4 -0
  90. data/lib/stationery/preview.rb +64 -0
  91. data/lib/stationery/rails/previews_controller.rb +61 -0
  92. data/lib/stationery/rails.rb +2 -0
  93. data/lib/stationery/railtie.rb +60 -0
  94. data/lib/stationery/regions.rb +67 -0
  95. data/lib/stationery/rich/nodes.rb +55 -0
  96. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  97. data/lib/stationery/rich/renderer.rb +121 -0
  98. data/lib/stationery/rich/styles.rb +34 -0
  99. data/lib/stationery/rspec.rb +6 -0
  100. data/lib/stationery/structure.rb +78 -0
  101. data/lib/stationery/svg/arc.rb +91 -0
  102. data/lib/stationery/svg/document.rb +73 -0
  103. data/lib/stationery/svg/parser.rb +38 -0
  104. data/lib/stationery/svg/path_data.rb +137 -0
  105. data/lib/stationery/svg/shapes.rb +54 -0
  106. data/lib/stationery/svg/style.rb +68 -0
  107. data/lib/stationery/svg/transform.rb +43 -0
  108. data/lib/stationery/testing/inspector.rb +98 -0
  109. data/lib/stationery/testing/matchers.rb +123 -0
  110. data/lib/stationery/text/entities/html4.rb +49 -0
  111. data/lib/stationery/text/entities.rb +6 -1
  112. data/lib/stationery/text/line.rb +8 -2
  113. data/lib/stationery/text/paragraph.rb +32 -11
  114. data/lib/stationery/text/runs_builder.rb +9 -1
  115. data/lib/stationery/text/style.rb +2 -2
  116. data/lib/stationery/text/wrapper.rb +3 -2
  117. data/lib/stationery/version.rb +1 -1
  118. data/lib/stationery/warnings.rb +65 -0
  119. data/lib/stationery.rb +51 -0
  120. 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
@@ -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, **options, &)
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
- def box(at: nil, align: nil, gap: 0, width: nil, keep_with_next: nil, **, &)
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
- def row(gap: 0, align: :top)
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
- @_builder.add(Layout::Row.new(columns.nodes, gap:, align:))
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
- @_builder.add(Layout::Box.new(container(align:, gap:, &), width:, **))
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
- def table(rows, widths: nil, width: :auto, header: false, cell: {}, &)
51
- @_builder.add(Layout::Table.new(rows, context: @_builder.context, widths:, width:, header:, cell:, &))
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
@@ -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 (CFF, collections, WOFF, …).
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