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,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inspector"
4
+
5
+ module Stationery
6
+ module Testing
7
+ # Framework-independent PDF matchers: each answers matches?(subject),
8
+ # failure_message, failure_message_when_negated and description, so RSpec
9
+ # uses them directly and Minitest assertions wrap them.
10
+ module Matchers
11
+ EXCERPT = 500
12
+
13
+ class Base
14
+ def initialize(expected = nil)
15
+ @expected = expected
16
+ end
17
+
18
+ def matches?(subject)
19
+ @inspector = subject.is_a?(Inspector) ? subject : Inspector.new(subject)
20
+ match?(@inspector)
21
+ end
22
+
23
+ def failure_message = "expected PDF to #{description}, #{actual}"
24
+ def failure_message_when_negated = "expected PDF not to #{description}, #{actual}"
25
+
26
+ private
27
+
28
+ def show(expected) = expected.is_a?(Regexp) ? expected.inspect : expected.to_s.inspect
29
+
30
+ def contains?(haystack, expected)
31
+ expected.is_a?(Regexp) ? expected.match?(haystack) : haystack.include?(expected.to_s)
32
+ end
33
+
34
+ def excerpt(text)
35
+ text.length > EXCERPT ? "#{text[0, EXCERPT]}…" : text
36
+ end
37
+ end
38
+
39
+ class HaveText < Base
40
+ def description = "have text #{show(@expected)}"
41
+
42
+ private
43
+
44
+ def match?(pdf) = contains?(pdf.text, @expected)
45
+ def actual = "got text:\n#{excerpt(@inspector.text)}"
46
+ end
47
+
48
+ class HaveTextOnPage < Base
49
+ def initialize(page, expected)
50
+ super(expected)
51
+ @page = page
52
+ end
53
+
54
+ def description = "have text #{show(@expected)} on page #{@page}"
55
+
56
+ private
57
+
58
+ def page_text = @inspector.page_texts.fetch(@page - 1, nil)
59
+ def match?(_pdf) = !page_text.nil? && contains?(page_text, @expected)
60
+
61
+ def actual
62
+ return "but it has #{@inspector.page_count} page(s)" if page_text.nil?
63
+
64
+ "got text on page #{@page}:\n#{excerpt(page_text)}"
65
+ end
66
+ end
67
+
68
+ class HavePageCount < Base
69
+ def description = "have #{@expected} page(s)"
70
+
71
+ private
72
+
73
+ def match?(pdf) = pdf.page_count == @expected
74
+ def actual = "got #{@inspector.page_count}"
75
+ end
76
+
77
+ class HaveLink < Base
78
+ def description = "have a link to #{show(@expected)}"
79
+
80
+ private
81
+
82
+ def match?(pdf) = pdf.links.any? { |url| @expected.is_a?(Regexp) ? @expected.match?(url) : url == @expected }
83
+ def actual = "got links #{@inspector.links.inspect}"
84
+ end
85
+
86
+ class HaveImageCount < Base
87
+ def description = "have #{@expected} image(s)"
88
+
89
+ private
90
+
91
+ def match?(pdf) = pdf.image_count == @expected
92
+ def actual = "got #{@inspector.image_count}"
93
+ end
94
+
95
+ class HaveBookmark < Base
96
+ def description = "have a bookmark #{show(@expected)}"
97
+
98
+ private
99
+
100
+ def match?(pdf) = pdf.bookmarks.include?(@expected)
101
+ def actual = "got bookmarks #{@inspector.bookmarks.inspect}"
102
+ end
103
+
104
+ class HaveNoWarnings < Base
105
+ def description = "have no warnings"
106
+
107
+ private
108
+
109
+ def match?(pdf) = pdf.warnings.empty?
110
+ def messages = @inspector.warnings.map(&:message)
111
+ def actual = messages.empty? ? "got none" : "got:\n#{messages.map { |m| " - #{m}" }.join("\n")}"
112
+ end
113
+
114
+ def have_pdf_text(expected) = HaveText.new(expected)
115
+ def have_pdf_text_on_page(page, expected) = HaveTextOnPage.new(page, expected)
116
+ def have_page_count(expected) = HavePageCount.new(expected)
117
+ def have_pdf_link(expected) = HaveLink.new(expected)
118
+ def have_image_count(expected) = HaveImageCount.new(expected)
119
+ def have_bookmark(title) = HaveBookmark.new(title)
120
+ def have_no_warnings = HaveNoWarnings.new
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ module Entities
6
+ # The 252 named character references of HTML 4.
7
+ HTML4 = { # rubocop:disable Metrics/CollectionLiteralLength
8
+ "AElig" => "Æ", "Aacute" => "Á", "Acirc" => "Â", "Agrave" => "À", "Alpha" => "Α", "Aring" => "Å",
9
+ "Atilde" => "Ã", "Auml" => "Ä", "Beta" => "Β", "Ccedil" => "Ç", "Chi" => "Χ", "Dagger" => "‡", "Delta" => "Δ",
10
+ "ETH" => "Ð", "Eacute" => "É", "Ecirc" => "Ê", "Egrave" => "È", "Epsilon" => "Ε", "Eta" => "Η", "Euml" => "Ë",
11
+ "Gamma" => "Γ", "Iacute" => "Í", "Icirc" => "Î", "Igrave" => "Ì", "Iota" => "Ι", "Iuml" => "Ï", "Kappa" => "Κ",
12
+ "Lambda" => "Λ", "Mu" => "Μ", "Ntilde" => "Ñ", "Nu" => "Ν", "OElig" => "Œ", "Oacute" => "Ó", "Ocirc" => "Ô",
13
+ "Ograve" => "Ò", "Omega" => "Ω", "Omicron" => "Ο", "Oslash" => "Ø", "Otilde" => "Õ", "Ouml" => "Ö",
14
+ "Phi" => "Φ", "Pi" => "Π", "Prime" => "″", "Psi" => "Ψ", "Rho" => "Ρ", "Scaron" => "Š", "Sigma" => "Σ",
15
+ "THORN" => "Þ", "Tau" => "Τ", "Theta" => "Θ", "Uacute" => "Ú", "Ucirc" => "Û", "Ugrave" => "Ù",
16
+ "Upsilon" => "Υ", "Uuml" => "Ü", "Xi" => "Ξ", "Yacute" => "Ý", "Yuml" => "Ÿ", "Zeta" => "Ζ", "aacute" => "á",
17
+ "acirc" => "â", "acute" => "´", "aelig" => "æ", "agrave" => "à", "alefsym" => "ℵ", "alpha" => "α", "amp" => "&",
18
+ "and" => "∧", "ang" => "∠", "aring" => "å", "asymp" => "≈", "atilde" => "ã", "auml" => "ä", "bdquo" => "„",
19
+ "beta" => "β", "brvbar" => "¦", "bull" => "•", "cap" => "∩", "ccedil" => "ç", "cedil" => "¸", "cent" => "¢",
20
+ "chi" => "χ", "circ" => "ˆ", "clubs" => "♣", "cong" => "≅", "copy" => "©", "crarr" => "↵", "cup" => "∪",
21
+ "curren" => "¤", "dArr" => "⇓", "dagger" => "†", "darr" => "↓", "deg" => "°", "delta" => "δ", "diams" => "♦",
22
+ "divide" => "÷", "eacute" => "é", "ecirc" => "ê", "egrave" => "è", "empty" => "∅", "emsp" => "\u2003",
23
+ "ensp" => "\u2002", "epsilon" => "ε", "equiv" => "≡", "eta" => "η", "eth" => "ð", "euml" => "ë", "euro" => "€",
24
+ "exist" => "∃", "fnof" => "ƒ", "forall" => "∀", "frac12" => "½", "frac14" => "¼", "frac34" => "¾",
25
+ "frasl" => "⁄", "gamma" => "γ", "ge" => "≥", "gt" => ">", "hArr" => "⇔", "harr" => "↔", "hearts" => "♥",
26
+ "hellip" => "…", "iacute" => "í", "icirc" => "î", "iexcl" => "¡", "igrave" => "ì", "image" => "ℑ",
27
+ "infin" => "∞", "int" => "∫", "iota" => "ι", "iquest" => "¿", "isin" => "∈", "iuml" => "ï", "kappa" => "κ",
28
+ "lArr" => "⇐", "lambda" => "λ", "lang" => "〈", "laquo" => "«", "larr" => "←", "lceil" => "⌈", "ldquo" => "“",
29
+ "le" => "≤", "lfloor" => "⌊", "lowast" => "∗", "loz" => "◊", "lrm" => "\u200E", "lsaquo" => "‹", "lsquo" => "‘",
30
+ "lt" => "<", "macr" => "¯", "mdash" => "—", "micro" => "µ", "middot" => "·", "minus" => "−", "mu" => "μ",
31
+ "nabla" => "∇", "nbsp" => "\u00A0", "ndash" => "–", "ne" => "≠", "ni" => "∋", "not" => "¬", "notin" => "∉",
32
+ "nsub" => "⊄", "ntilde" => "ñ", "nu" => "ν", "oacute" => "ó", "ocirc" => "ô", "oelig" => "œ", "ograve" => "ò",
33
+ "oline" => "‾", "omega" => "ω", "omicron" => "ο", "oplus" => "⊕", "or" => "∨", "ordf" => "ª", "ordm" => "º",
34
+ "oslash" => "ø", "otilde" => "õ", "otimes" => "⊗", "ouml" => "ö", "para" => "¶", "part" => "∂", "permil" => "‰",
35
+ "perp" => "⊥", "phi" => "φ", "pi" => "π", "piv" => "ϖ", "plusmn" => "±", "pound" => "£", "prime" => "′",
36
+ "prod" => "∏", "prop" => "∝", "psi" => "ψ", "quot" => "\"", "rArr" => "⇒", "radic" => "√", "rang" => "〉",
37
+ "raquo" => "»", "rarr" => "→", "rceil" => "⌉", "rdquo" => "”", "real" => "ℜ", "reg" => "®", "rfloor" => "⌋",
38
+ "rho" => "ρ", "rlm" => "\u200F", "rsaquo" => "›", "rsquo" => "’", "sbquo" => "‚", "scaron" => "š",
39
+ "sdot" => "⋅", "sect" => "§", "shy" => "\u00AD", "sigma" => "σ", "sigmaf" => "ς", "sim" => "∼", "spades" => "♠",
40
+ "sub" => "⊂", "sube" => "⊆", "sum" => "∑", "sup" => "⊃", "sup1" => "¹", "sup2" => "²", "sup3" => "³",
41
+ "supe" => "⊇", "szlig" => "ß", "tau" => "τ", "there4" => "∴", "theta" => "θ", "thetasym" => "ϑ",
42
+ "thinsp" => "\u2009", "thorn" => "þ", "tilde" => "˜", "times" => "×", "trade" => "™", "uArr" => "⇑",
43
+ "uacute" => "ú", "uarr" => "↑", "ucirc" => "û", "ugrave" => "ù", "uml" => "¨", "upsih" => "ϒ", "upsilon" => "υ",
44
+ "uuml" => "ü", "weierp" => "℘", "xi" => "ξ", "yacute" => "ý", "yen" => "¥", "yuml" => "ÿ", "zeta" => "ζ",
45
+ "zwj" => "\u200D", "zwnj" => "\u200C"
46
+ }.freeze
47
+ end
48
+ end
49
+ end
@@ -14,10 +14,15 @@ module Stationery
14
14
  name = Regexp.last_match(1)
15
15
  if name.start_with?("#x") then [name[2..].to_i(16)].pack("U")
16
16
  elsif name.start_with?("#") then [name[1..].to_i].pack("U")
17
- else NAMED.fetch(name, entity)
17
+ else NAMED.fetch(name) { html4.fetch(name, entity) }
18
18
  end
19
19
  end
20
20
  end
21
+
22
+ def html4
23
+ require_relative "entities/html4" unless defined?(HTML4)
24
+ HTML4
25
+ end
21
26
  end
22
27
  end
23
28
  end
@@ -7,12 +7,14 @@ module Stationery
7
7
  Fragment = Data.define(:text, :style, :font, :face, :width, :x)
8
8
 
9
9
  # One laid-out line. Vertical metrics come from its tallest fragment (or
10
- # from the paragraph's base style when the line is empty).
10
+ # from the paragraph's base style when the line is empty). A soft-wrapped
11
+ # line is justifiable; the last line and one ended by a newline are not.
11
12
  class Line
12
13
  attr_reader :fragments, :width, :ascent, :descent, :height
13
14
 
14
- def initialize(fragments, fallback)
15
+ def initialize(fragments, fallback, justifiable: false)
15
16
  @fragments = fragments
17
+ @justifiable = justifiable
16
18
  @width = fragments.sum(&:width)
17
19
  metrics = fragments.empty? ? [fallback] : fragments.map { |f| [f.font, f.style.render_size] }
18
20
  @ascent = metrics.map { |font, size| font.ascender(size) }.max
@@ -21,6 +23,10 @@ module Stationery
21
23
  end
22
24
 
23
25
  def text = fragments.map(&:text).join
26
+ def justifiable? = @justifiable
27
+
28
+ # U+0020 spaces only: justification stretches those, never tabs.
29
+ def space_count = fragments.sum { |fragment| fragment.text.count(" ") }
24
30
  end
25
31
  end
26
32
  end
@@ -10,13 +10,13 @@ module Stationery
10
10
  attr_reader :runs, :lines, :width, :align, :leading
11
11
 
12
12
  def initialize(runs, book:, width:, align: :left, leading: 0, lines: nil, fallback_style: nil)
13
- @runs = runs
13
+ @runs = lines ? runs : book.fallback(runs)
14
14
  @book = book
15
15
  @width = width
16
16
  @align = align
17
17
  @leading = leading
18
18
  @fallback_style = fallback_style || runs.first&.style
19
- @lines = lines || Wrapper.new(book).wrap(runs, width, fallback_style: @fallback_style)
19
+ @lines = lines || Wrapper.new(book).wrap(@runs, width, fallback_style: @fallback_style)
20
20
  end
21
21
 
22
22
  def height
@@ -47,7 +47,12 @@ module Stationery
47
47
  top = y
48
48
  @lines.each do |line|
49
49
  offset = x + Geometry.align_offset(@align, @width, line.width)
50
- line.fragments.each { |fragment| draw_fragment(canvas, fragment, offset, top, line) }
50
+ extra = word_spacing(line)
51
+ spaces = 0
52
+ line.fragments.each do |fragment|
53
+ draw_fragment(canvas, fragment, offset + (extra * spaces), top, line, extra)
54
+ spaces += fragment.text.count(" ")
55
+ end
51
56
  top += line.height + @leading
52
57
  end
53
58
  end
@@ -82,16 +87,32 @@ module Stationery
82
87
  @runs.map { |run| Run.new(run.text, run.style.with(size: [run.style.size * factor, MIN_SHRINK_SIZE].max)) }
83
88
  end
84
89
 
85
- def draw_fragment(canvas, fragment, offset, top, line)
90
+ # Extra points per space that stretch a justifiable line to the width.
91
+ def word_spacing(line)
92
+ return 0 unless @align == :justify && line.justifiable? && line.space_count.positive?
93
+
94
+ [(@width - line.width) / line.space_count, 0].max
95
+ end
96
+
97
+ def draw_fragment(canvas, fragment, offset, top, line, word_spacing)
86
98
  style = fragment.style
99
+ count_missing(fragment)
87
100
  x = offset + fragment.x
88
- canvas.text(fragment.text, x:, y: top + line.ascent, font: fragment.font, size: style.render_size,
89
- color: style.color, letter_spacing: style.letter_spacing, rise: style.rise,
90
- opacity: style.opacity, underline: style.underline,
91
- strikethrough: style.strikethrough,
92
- synthetic_bold: fragment.face.synthetic_bold,
93
- synthetic_oblique: fragment.face.synthetic_oblique)
94
- canvas.link(x, top, fragment.width, line.height, style.link) if style.link
101
+ width = canvas.text(fragment.text, x:, y: top + line.ascent, font: fragment.font, size: style.render_size,
102
+ color: style.color, letter_spacing: style.letter_spacing, rise: style.rise,
103
+ opacity: style.opacity, underline: style.underline,
104
+ strikethrough: style.strikethrough, kerning: style.kerning,
105
+ synthetic_bold: fragment.face.synthetic_bold,
106
+ synthetic_oblique: fragment.face.synthetic_oblique, word_spacing:)
107
+ canvas.link(x, top, width, line.height, style.link) if style.link
108
+ end
109
+
110
+ def count_missing(fragment)
111
+ fragment.text.each_char do |char|
112
+ next if Fonts::Fallback.carried?(char) || fragment.font.glyph?(char)
113
+
114
+ @book.warnings.missing_glyph(char, fragment.style.family)
115
+ end
95
116
  end
96
117
  end
97
118
  end
@@ -19,9 +19,17 @@ module Stationery
19
19
 
20
20
  # A block taking an argument is called with the builder (so it keeps its
21
21
  # own self and instance variables); one without is evaluated on it.
22
+ # Once a block has taken the builder as an argument, nested blocks are
23
+ # called as closures too, so `t.link(url) { t.color(tone, "go") }` can
24
+ # still reach the caller's methods.
22
25
  def capture(&block)
23
26
  before = @runs.size
24
- result = block.arity == 1 ? yield(self) : instance_exec(&block)
27
+ result = if block.arity == 1 || @yielding
28
+ @yielding = true
29
+ block.arity.zero? ? yield : yield(self)
30
+ else
31
+ instance_exec(&block)
32
+ end
25
33
  plain(result) if result.is_a?(String) && @runs.size == before
26
34
  end
27
35
 
@@ -6,9 +6,9 @@ module Stationery
6
6
 
7
7
  # How a run of text looks. Immutable; derive variants with #with.
8
8
  Style = Data.define(:family, :size, :weight, :style, :color, :letter_spacing,
9
- :underline, :strikethrough, :script, :link, :opacity) do
9
+ :underline, :strikethrough, :script, :link, :opacity, :kerning) do
10
10
  def initialize(family:, size: 10, weight: :regular, style: :normal, color: "#000000", letter_spacing: 0,
11
- underline: false, strikethrough: false, script: nil, link: nil, opacity: nil)
11
+ underline: false, strikethrough: false, script: nil, link: nil, opacity: nil, kerning: true)
12
12
  super
13
13
  end
14
14
 
@@ -91,7 +91,7 @@ module Stationery
91
91
 
92
92
  def finish(wrapped: false)
93
93
  segments = wrapped ? @current : @current + @pending_space
94
- @lines << Line.new(fragments(trim_trailing(segments)), fallback_metrics)
94
+ @lines << Line.new(fragments(trim_trailing(segments)), fallback_metrics, justifiable: wrapped)
95
95
  @current = []
96
96
  @pending_space = []
97
97
  end
@@ -124,7 +124,8 @@ module Stationery
124
124
  end
125
125
 
126
126
  def measure(text, style)
127
- @book.resolve(style).first.width_of(text, style.render_size, letter_spacing: style.letter_spacing)
127
+ font = @book.resolve(style).first
128
+ font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning)
128
129
  end
129
130
  end
130
131
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Everything a render noticed but did not raise on. One collector per
5
+ # render; equal warnings are kept once and missing glyphs are counted per
6
+ # character and family.
7
+ class Warnings
8
+ include Enumerable
9
+
10
+ Overflow = Layout::Overflow
11
+
12
+ MissingGlyph = Data.define(:char, :family, :count) do
13
+ def message
14
+ times = count == 1 ? "once" : "#{count} times"
15
+ format('missing glyph "%<char>s" (U+%<code>04X) in %<family>s, drawn %<times>s as .notdef',
16
+ char:, code: char.ord, family:, times:)
17
+ end
18
+ end
19
+
20
+ UnknownFamily = Data.define(:requested, :used) do
21
+ def message = %(font family "#{requested}" is not registered, using "#{used}")
22
+ end
23
+
24
+ UnsupportedSvg = Data.define(:elements, :source) do
25
+ def message = %(SVG "#{source}" uses unsupported elements: #{elements.join(", ")})
26
+ end
27
+
28
+ SkippedImage = Data.define(:source, :reason) do
29
+ def message = %(image "#{source}" skipped: #{reason})
30
+ end
31
+
32
+ UnresolvedLink = Data.define(:name, :page) do
33
+ def message = %(link to "#{name}" on page #{page} has no matching anchor)
34
+ end
35
+
36
+ DuplicateAnchor = Data.define(:name, :page) do
37
+ def message = %(anchor "#{name}" on page #{page} is already defined)
38
+ end
39
+
40
+ def initialize
41
+ @items = []
42
+ @glyphs = Hash.new(0)
43
+ end
44
+
45
+ def <<(warning)
46
+ @items << warning unless @items.include?(warning)
47
+ self
48
+ end
49
+
50
+ def missing_glyph(char, family)
51
+ @glyphs[[char, family]] += 1
52
+ end
53
+
54
+ def each(&)
55
+ return enum_for(:each) unless block_given?
56
+
57
+ @items.each(&)
58
+ @glyphs.each { |(char, family), count| yield MissingGlyph.new(char:, family:, count:) }
59
+ self
60
+ end
61
+
62
+ def size = @items.size + @glyphs.size
63
+ def empty? = size.zero?
64
+ end
65
+ end
data/lib/stationery.rb CHANGED
@@ -6,13 +6,31 @@ require_relative "stationery/pdf/types"
6
6
  require_relative "stationery/pdf/serializer"
7
7
  require_relative "stationery/pdf/stream"
8
8
  require_relative "stationery/pdf/writer"
9
+ require_relative "stationery/fonts/sfnt_metrics"
9
10
  require_relative "stationery/fonts/true_type"
11
+ require_relative "stationery/fonts/cff"
12
+ require_relative "stationery/fonts/cff_subset"
10
13
  require_relative "stationery/fonts/cmap"
11
14
  require_relative "stationery/fonts/name_table"
15
+ require_relative "stationery/fonts/kern_table"
16
+ require_relative "stationery/fonts/gpos"
17
+ require_relative "stationery/fonts/gpos/coverage"
18
+ require_relative "stationery/fonts/gpos/class_def"
19
+ require_relative "stationery/fonts/gpos/pair_pos"
20
+ require_relative "stationery/fonts/kerning"
12
21
  require_relative "stationery/fonts/subset"
13
22
  require_relative "stationery/fonts/registry"
23
+ require_relative "stationery/fonts/glyph_run"
24
+ require_relative "stationery/fonts/to_unicode"
25
+ require_relative "stationery/fonts/embedding/base"
26
+ require_relative "stationery/fonts/embedding/true_type"
27
+ require_relative "stationery/fonts/embedding/cff"
14
28
  require_relative "stationery/fonts/font"
15
29
  require_relative "stationery/fonts/family"
30
+ require_relative "stationery/fonts/bundled"
31
+ require_relative "stationery/fonts/catalog"
32
+ require_relative "stationery/fonts/packs"
33
+ require_relative "stationery/fonts/installer"
16
34
  require_relative "stationery/images/images"
17
35
  require_relative "stationery/images/cache"
18
36
  require_relative "stationery/images/scanlines"
@@ -24,9 +42,12 @@ require_relative "stationery/page"
24
42
  require_relative "stationery/resources"
25
43
  require_relative "stationery/canvas/path"
26
44
  require_relative "stationery/canvas/text"
45
+ require_relative "stationery/canvas/debug"
27
46
  require_relative "stationery/canvas"
47
+ require_relative "stationery/pdf/outline_writer"
28
48
  require_relative "stationery/pdf/assembler"
29
49
  require_relative "stationery/text/style"
50
+ require_relative "stationery/fonts/fallback"
30
51
  require_relative "stationery/fonts/font_book"
31
52
  require_relative "stationery/text/entities"
32
53
  require_relative "stationery/text/markup"
@@ -35,24 +56,54 @@ require_relative "stationery/text/line"
35
56
  require_relative "stationery/text/wrapper"
36
57
  require_relative "stationery/text/paragraph"
37
58
  require_relative "stationery/layout/node"
59
+ require_relative "stationery/warnings"
38
60
  require_relative "stationery/layout/flow"
39
61
  require_relative "stationery/layout/box"
40
62
  require_relative "stationery/layout/row"
41
63
  require_relative "stationery/layout/text"
42
64
  require_relative "stationery/layout/image"
43
65
  require_relative "stationery/layout/leaves"
66
+ require_relative "stationery/layout/mark"
67
+ require_relative "stationery/layout/table_of_contents"
68
+ require_relative "stationery/layout/table_of_contents/entry"
44
69
  require_relative "stationery/layout/table"
45
70
  require_relative "stationery/layout/table/widths"
46
71
  require_relative "stationery/layout/table/cell"
72
+ require_relative "stationery/layout/table/grid"
47
73
  require_relative "stationery/layout/table/selection"
74
+ require_relative "stationery/layout/table/row_splitter"
48
75
  require_relative "stationery/layout/paginator"
76
+ require_relative "stationery/svg/path_data"
77
+ require_relative "stationery/svg/arc"
78
+ require_relative "stationery/svg/parser"
79
+ require_relative "stationery/svg/transform"
80
+ require_relative "stationery/svg/style"
81
+ require_relative "stationery/svg/shapes"
82
+ require_relative "stationery/svg/document"
83
+ require_relative "stationery/layout/svg"
84
+ require_relative "stationery/layout/wrap"
49
85
  require_relative "stationery/layout/positioned"
86
+ require_relative "stationery/layout/list_item"
87
+ require_relative "stationery/list_markers"
88
+ require_relative "stationery/outline"
50
89
  require_relative "stationery/builder"
51
90
  require_relative "stationery/elements"
91
+ require_relative "stationery/elements/lists"
92
+ require_relative "stationery/elements/rich"
52
93
  require_relative "stationery/component"
94
+ require_relative "stationery/regions"
53
95
  require_relative "stationery/page_templates"
96
+ require_relative "stationery/structure"
54
97
  require_relative "stationery/document"
55
98
 
56
99
  # Pure-Ruby PDF documents built from Phlex-style components.
57
100
  module Stationery
101
+ @font_paths = []
102
+
103
+ class << self
104
+ # Directories searched for font files; the Railtie appends the app's.
105
+ attr_reader :font_paths
106
+ end
107
+ # Names of the font families shipped inside the gem.
108
+ def self.bundled_fonts = Fonts::Bundled.names
58
109
  end