stationery 0.6.0 → 0.8.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +198 -18
  4. data/lib/stationery/builder.rb +5 -3
  5. data/lib/stationery/canvas/text.rb +4 -3
  6. data/lib/stationery/canvas.rb +24 -2
  7. data/lib/stationery/cli/render.rb +19 -4
  8. data/lib/stationery/document.rb +72 -11
  9. data/lib/stationery/elements.rb +1 -1
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/fallback.rb +3 -3
  12. data/lib/stationery/fonts/font.rb +39 -21
  13. data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
  14. data/lib/stationery/fonts/gsub.rb +65 -20
  15. data/lib/stationery/fonts/ligatures.rb +6 -4
  16. data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
  17. data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
  18. data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
  19. data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
  20. data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
  21. data/lib/stationery/hyphenation.rb +132 -0
  22. data/lib/stationery/images/jpeg.rb +2 -1
  23. data/lib/stationery/images/png.rb +66 -0
  24. data/lib/stationery/images/resampled.rb +80 -0
  25. data/lib/stationery/layout/image.rb +28 -2
  26. data/lib/stationery/layout/paginator.rb +1 -1
  27. data/lib/stationery/layout/text.rb +2 -2
  28. data/lib/stationery/minitest.rb +17 -0
  29. data/lib/stationery/page_templates.rb +2 -1
  30. data/lib/stationery/pdf/assembler.rb +40 -6
  31. data/lib/stationery/pdf/attachments.rb +76 -0
  32. data/lib/stationery/pdf/conformance.rb +139 -0
  33. data/lib/stationery/pdf/factur_x.rb +117 -0
  34. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  35. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  36. data/lib/stationery/pdf/page_labels.rb +71 -0
  37. data/lib/stationery/pdf/stream.rb +5 -3
  38. data/lib/stationery/pdf/xmp.rb +149 -0
  39. data/lib/stationery/resources.rb +3 -0
  40. data/lib/stationery/svg/clip_path.rb +69 -0
  41. data/lib/stationery/svg/document.rb +26 -22
  42. data/lib/stationery/svg/painter.rb +11 -0
  43. data/lib/stationery/svg/region.rb +25 -0
  44. data/lib/stationery/svg/shapes.rb +2 -0
  45. data/lib/stationery/svg/style.rb +25 -14
  46. data/lib/stationery/svg/viewport.rb +78 -0
  47. data/lib/stationery/svg/walker.rb +142 -0
  48. data/lib/stationery/testing/inspector.rb +81 -0
  49. data/lib/stationery/testing/matchers.rb +77 -0
  50. data/lib/stationery/text/entities.rb +14 -4
  51. data/lib/stationery/text/paragraph.rb +1 -1
  52. data/lib/stationery/text/style.rb +14 -3
  53. data/lib/stationery/text/wrapper.rb +73 -12
  54. data/lib/stationery/version.rb +1 -1
  55. data/lib/stationery/warnings.rb +10 -0
  56. data/lib/stationery.rb +12 -0
  57. metadata +20 -1
@@ -2,23 +2,33 @@
2
2
 
3
3
  module Stationery
4
4
  module Text
5
- # Decodes the HTML entities markup text may carry.
5
+ # Decodes the HTML character references markup text may carry: numeric
6
+ # (`'`, `'`, `'`) and named (the built-ins below, then the
7
+ # HTML 4 table). Each reference is decoded once, so `'` yields the
8
+ # literal text `'`; an unknown name, a malformed reference or a code
9
+ # point outside Unicode stays as written.
6
10
  module Entities
7
11
  NAMED = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => "\"", "apos" => "'", "nbsp" => " " }.freeze
8
- PATTERN = /&(#x\h+|#\d+|[a-zA-Z]+);/
12
+ PATTERN = /&(#[xX]\h+|#\d+|[a-zA-Z]+);/
13
+ MAX_CODE_POINT = 0x10FFFF
14
+ SURROGATES = (0xD800..0xDFFF)
9
15
 
10
16
  module_function
11
17
 
12
18
  def decode(text)
13
19
  text.gsub(PATTERN) do |entity|
14
20
  name = Regexp.last_match(1)
15
- if name.start_with?("#x") then [name[2..].to_i(16)].pack("U")
16
- elsif name.start_with?("#") then [name[1..].to_i].pack("U")
21
+ if name.start_with?("#x", "#X") then character(name[2..].to_i(16), entity)
22
+ elsif name.start_with?("#") then character(name[1..].to_i, entity)
17
23
  else NAMED.fetch(name) { html4.fetch(name, entity) }
18
24
  end
19
25
  end
20
26
  end
21
27
 
28
+ def character(code, entity)
29
+ code.between?(1, MAX_CODE_POINT) && !SURROGATES.cover?(code) ? [code].pack("U") : entity
30
+ end
31
+
22
32
  def html4
23
33
  require_relative "entities/html4" unless defined?(HTML4)
24
34
  HTML4
@@ -117,7 +117,7 @@ module Stationery
117
117
  color: style.color, letter_spacing: style.letter_spacing, rise: style.rise,
118
118
  opacity: style.opacity, underline: style.underline,
119
119
  strikethrough: style.strikethrough, kerning: style.kerning,
120
- ligatures: style.ligatures,
120
+ ligatures: style.ligatures, features: style.features,
121
121
  synthetic_bold: fragment.face.synthetic_bold,
122
122
  synthetic_oblique: fragment.face.synthetic_oblique, word_spacing:)
123
123
  canvas.link(x, top, width, line.height, style.link, tag: link) if style.link
@@ -3,15 +3,26 @@
3
3
  module Stationery
4
4
  module Text
5
5
  SCRIPT_SCALE = 0.583
6
+ NO_FEATURES = [].freeze
6
7
 
7
8
  # How a run of text looks. Immutable; derive variants with #with.
9
+ # `hyphenate` is a bundled hyphenation language (see Hyphenation) or nil.
8
10
  Style = Data.define(:family, :size, :weight, :style, :color, :letter_spacing,
9
11
  :underline, :strikethrough, :script, :link, :opacity, :kerning,
10
- :ligatures) do
12
+ :ligatures, :features, :hyphenate) do
13
+ # `features:` are OpenType feature tags (`:smcp`, `"onum"`, …) applied on
14
+ # top of `ligatures:`; kept as sorted frozen Strings so equal sets are
15
+ # equal styles.
11
16
  def initialize(family:, size: 10, weight: :regular, style: :normal, color: "#000000", letter_spacing: 0,
12
17
  underline: false, strikethrough: false, script: nil, link: nil, opacity: nil, kerning: true,
13
- ligatures: true)
14
- super
18
+ ligatures: true, features: NO_FEATURES, hyphenate: nil)
19
+ super(family:, size:, weight:, style:, color:, letter_spacing:, underline:, strikethrough:, script:, link:,
20
+ opacity:, kerning:, ligatures:, features: Style.features(features), hyphenate: Hyphenation.tag(hyphenate))
21
+ end
22
+
23
+ def self.features(tags)
24
+ tags = Array(tags).map(&:to_s).uniq.sort
25
+ tags.empty? ? NO_FEATURES : tags.freeze
15
26
  end
16
27
 
17
28
  # The size glyphs are drawn at: smaller for sub- and superscript.
@@ -3,11 +3,16 @@
3
3
  module Stationery
4
4
  module Text
5
5
  # Greedy line breaking over styled runs. Breaks at spaces (the space is
6
- # dropped at the break) and after hyphens; a word wider than the line is
7
- # broken between characters. A word that changes style midway ("<b>Tot</b>al")
8
- # is still one word.
6
+ # dropped at the break) and after hyphens; a word that does not fit is
7
+ # hyphenated when it may be (a soft hyphen U+00AD names the break points,
8
+ # else the style's `hyphenate` language), and a word wider than the line
9
+ # is broken between characters. A word that changes style midway
10
+ # ("<b>Tot</b>al") is still one word. Soft hyphens are never measured or
11
+ # drawn: a broken word draws "-" at the break and nothing otherwise.
9
12
  class Wrapper
10
13
  TOKEN = /\n|[ \t]+|[^ \t\n-]*-+|[^ \t\n-]+/
14
+ SOFT_HYPHEN = "­"
15
+ EPSILON = 0.0001
11
16
 
12
17
  Segment = Data.define(:text, :style)
13
18
 
@@ -53,16 +58,70 @@ module Stationery
53
58
  end
54
59
  end
55
60
 
56
- def place_word(word)
61
+ # A word holding soft hyphens breaks only at them, even once the part
62
+ # after a break carries none (TeX's rule: a discretionary hyphen exempts
63
+ # the whole word from the patterns).
64
+ def place_word(word, explicit: word.any? { |segment| segment.text.include?(SOFT_HYPHEN) })
65
+ @explicit = explicit
57
66
  needed = width(@pending_space) + width(word)
58
- if @current.empty? || line_width + needed <= @max + 0.0001
67
+ if @current.empty? || line_width + needed <= @max + EPSILON
59
68
  @current.concat(@pending_space, word)
69
+ elsif (head, tail = hyphenated(word, @max - line_width - width(@pending_space)))
70
+ @current.concat(@pending_space, head)
71
+ finish(wrapped: true)
72
+ return place_word(tail, explicit:)
60
73
  else
61
74
  finish(wrapped: true)
62
75
  @current.concat(word)
63
76
  end
64
77
  @pending_space = []
65
- break_long_word while line_width > @max + 0.0001 && characters(@current) > 1
78
+ overflow
79
+ end
80
+
81
+ # A word alone on its line and still too wide: hyphenate what fits,
82
+ # else move the characters that overflow onto following lines.
83
+ def overflow
84
+ while line_width > @max + EPSILON && characters(@current) > 1
85
+ head, tail = hyphenated(@current, @max)
86
+ if head
87
+ @current = head
88
+ finish(wrapped: true)
89
+ @current = tail
90
+ else
91
+ break_long_word
92
+ end
93
+ end
94
+ end
95
+
96
+ # [head, tail] with the longest hyphenation of `word` whose head (and
97
+ # its hyphen) fits `available`; nil when the word offers no break that
98
+ # fits. Soft hyphens name the breaks and suppress the patterns.
99
+ def hyphenated(word, available)
100
+ chars = word.flat_map { |segment| segment.text.chars.map { |char| Segment.new(char, segment.style) } }
101
+ break_points(chars).reverse_each do |index|
102
+ head = chars.first(index).reject { |segment| segment.text == SOFT_HYPHEN }
103
+ head << Segment.new(hyphen_for(chars[index - 1].style), chars[index - 1].style)
104
+ return [head, chars.drop(index)] if width(head) <= available + EPSILON
105
+ end
106
+ nil
107
+ end
108
+
109
+ # Indexes into the word's characters before which it may break: after
110
+ # each soft hyphen, else where the language's patterns allow.
111
+ def break_points(chars)
112
+ return (1...chars.length).select { |index| chars[index - 1].text == SOFT_HYPHEN } if @explicit
113
+
114
+ text = chars.map(&:text).join
115
+ language = chars.first.style.hyphenate or return []
116
+ core = text[/\p{L}+/] or return []
117
+ offset = text.index(core)
118
+ Hyphenation.points(core, language).map { |point| point + offset }
119
+ end
120
+
121
+ # The font's hyphen, or the dedicated U+2010 when it lacks "-".
122
+ def hyphen_for(style)
123
+ font = @book.resolve(style).first
124
+ !font.glyph?("-") && font.glyph?("‐") ? "‐" : "-"
66
125
  end
67
126
 
68
127
  # Moves characters that overflow the line onto following lines.
@@ -71,7 +130,7 @@ module Stationery
71
130
  @current.each do |segment|
72
131
  segment.text.each_char do |char|
73
132
  piece = Segment.new(char, segment.style)
74
- return keep_overflow(fitting, piece) if fitting.any? && width(fitting + [piece]) > @max + 0.0001
133
+ return keep_overflow(fitting, piece) if fitting.any? && width(fitting + [piece]) > @max + EPSILON
75
134
 
76
135
  fitting << piece
77
136
  end
@@ -104,8 +163,10 @@ module Stationery
104
163
 
105
164
  def fragments(segments)
106
165
  x = 0
107
- segments.chunk_while { |a, b| a.style == b.style }.map do |group|
108
- text = group.map(&:text).join
166
+ segments.chunk_while { |a, b| a.style == b.style }.filter_map do |group|
167
+ text = group.map(&:text).join.delete(SOFT_HYPHEN)
168
+ next if text.empty?
169
+
109
170
  font, face = @book.resolve(group.first.style)
110
171
  fragment_width = measure(text, group.first.style)
111
172
  Fragment.new(text, group.first.style, font, face, fragment_width, x).tap { x += fragment_width }
@@ -117,16 +178,16 @@ module Stationery
117
178
  end
118
179
 
119
180
  def line_width = width(@current)
120
- def characters(segments) = segments.sum { |segment| segment.text.length }
181
+ def characters(segments) = segments.sum { |segment| segment.text.delete(SOFT_HYPHEN).length }
121
182
 
122
183
  def width(segments)
123
- segments.sum { |segment| measure(segment.text, segment.style) }
184
+ segments.sum { |segment| measure(segment.text.delete(SOFT_HYPHEN), segment.style) }
124
185
  end
125
186
 
126
187
  def measure(text, style)
127
188
  font = @book.resolve(style).first
128
189
  font.width_of(text, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
129
- ligatures: style.ligatures)
190
+ ligatures: style.ligatures, features: style.features)
130
191
  end
131
192
  end
132
193
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -29,6 +29,12 @@ module Stationery
29
29
  def message = %(image "#{source}" skipped: #{reason})
30
30
  end
31
31
 
32
+ OversizedImage = Data.define(:source, :pixels, :ppi, :limit) do
33
+ def message
34
+ %(image "#{source}" #{pixels}px wide is drawn at #{ppi} ppi (limit #{limit}); resize it before embedding)
35
+ end
36
+ end
37
+
32
38
  UnresolvedLink = Data.define(:name, :page) do
33
39
  def message = %(link to "#{name}" on page #{page} has no matching anchor)
34
40
  end
@@ -49,6 +55,10 @@ module Stationery
49
55
  def message = "tagged PDF has no language: set metadata lang:"
50
56
  end
51
57
 
58
+ ConformanceIssue = Data.define(:level, :subject) do
59
+ def message = "#{PDF::Conformance.label(level)}: #{subject} is not covered by the sRGB output intent"
60
+ end
61
+
52
62
  def initialize
53
63
  @items = []
54
64
  @glyphs = Hash.new(0)
data/lib/stationery.rb CHANGED
@@ -27,6 +27,7 @@ require_relative "stationery/fonts/gpos/pair_pos"
27
27
  require_relative "stationery/fonts/kerning"
28
28
  require_relative "stationery/fonts/gsub"
29
29
  require_relative "stationery/fonts/gsub/ligature_subst"
30
+ require_relative "stationery/fonts/gsub/single_subst"
30
31
  require_relative "stationery/fonts/ligatures"
31
32
  require_relative "stationery/fonts/subset"
32
33
  require_relative "stationery/fonts/registry"
@@ -46,6 +47,7 @@ require_relative "stationery/images/cache"
46
47
  require_relative "stationery/images/scanlines"
47
48
  require_relative "stationery/images/jpeg"
48
49
  require_relative "stationery/images/png"
50
+ require_relative "stationery/images/resampled"
49
51
  require_relative "stationery/color"
50
52
  require_relative "stationery/geometry"
51
53
  require_relative "stationery/page"
@@ -59,11 +61,17 @@ require_relative "stationery/canvas/debug"
59
61
  require_relative "stationery/canvas/marking"
60
62
  require_relative "stationery/canvas"
61
63
  require_relative "stationery/pdf/outline_writer"
64
+ require_relative "stationery/pdf/page_labels"
65
+ require_relative "stationery/pdf/attachments"
66
+ require_relative "stationery/pdf/xmp"
67
+ require_relative "stationery/pdf/conformance"
68
+ require_relative "stationery/pdf/factur_x"
62
69
  require_relative "stationery/forms/metrics"
63
70
  require_relative "stationery/forms/field"
64
71
  require_relative "stationery/forms/appearance"
65
72
  require_relative "stationery/forms/acro_form"
66
73
  require_relative "stationery/pdf/assembler"
74
+ require_relative "stationery/hyphenation"
67
75
  require_relative "stationery/text/style"
68
76
  require_relative "stationery/fonts/fallback"
69
77
  require_relative "stationery/fonts/font_book"
@@ -104,6 +112,10 @@ require_relative "stationery/svg/gradient"
104
112
  require_relative "stationery/svg/shading"
105
113
  require_relative "stationery/svg/painter"
106
114
  require_relative "stationery/svg/text"
115
+ require_relative "stationery/svg/viewport"
116
+ require_relative "stationery/svg/region"
117
+ require_relative "stationery/svg/clip_path"
118
+ require_relative "stationery/svg/walker"
107
119
  require_relative "stationery/svg/document"
108
120
  require_relative "stationery/layout/svg"
109
121
  require_relative "stationery/layout/wrap"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stationery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -71,6 +71,7 @@ files:
71
71
  - lib/stationery/fonts/gpos/pair_pos.rb
72
72
  - lib/stationery/fonts/gsub.rb
73
73
  - lib/stationery/fonts/gsub/ligature_subst.rb
74
+ - lib/stationery/fonts/gsub/single_subst.rb
74
75
  - lib/stationery/fonts/installer.rb
75
76
  - lib/stationery/fonts/kern_table.rb
76
77
  - lib/stationery/fonts/kerning.rb
@@ -94,10 +95,17 @@ files:
94
95
  - lib/stationery/html/tree_builder/blocks.rb
95
96
  - lib/stationery/html/tree_builder/collector.rb
96
97
  - lib/stationery/html/whitespace.rb
98
+ - lib/stationery/hyphenation.rb
99
+ - lib/stationery/hyphenation/patterns/LICENSES.md
100
+ - lib/stationery/hyphenation/patterns/de-1996.pat.txt
101
+ - lib/stationery/hyphenation/patterns/en-us.hyp.txt
102
+ - lib/stationery/hyphenation/patterns/en-us.pat.txt
103
+ - lib/stationery/hyphenation/patterns/sv.pat.txt
97
104
  - lib/stationery/images/cache.rb
98
105
  - lib/stationery/images/images.rb
99
106
  - lib/stationery/images/jpeg.rb
100
107
  - lib/stationery/images/png.rb
108
+ - lib/stationery/images/resampled.rb
101
109
  - lib/stationery/images/scanlines.rb
102
110
  - lib/stationery/instrumentation.rb
103
111
  - lib/stationery/layout/box.rb
@@ -137,16 +145,23 @@ files:
137
145
  - lib/stationery/page.rb
138
146
  - lib/stationery/page_templates.rb
139
147
  - lib/stationery/pdf/assembler.rb
148
+ - lib/stationery/pdf/attachments.rb
149
+ - lib/stationery/pdf/conformance.rb
140
150
  - lib/stationery/pdf/encryption/aes.rb
141
151
  - lib/stationery/pdf/encryption/rc4.rb
142
152
  - lib/stationery/pdf/encryption/revision4.rb
143
153
  - lib/stationery/pdf/encryption/revision6.rb
144
154
  - lib/stationery/pdf/encryption/standard_security.rb
155
+ - lib/stationery/pdf/factur_x.rb
156
+ - lib/stationery/pdf/icc/LICENSE.md
157
+ - lib/stationery/pdf/icc/sRGB2014.icc
145
158
  - lib/stationery/pdf/outline_writer.rb
159
+ - lib/stationery/pdf/page_labels.rb
146
160
  - lib/stationery/pdf/serializer.rb
147
161
  - lib/stationery/pdf/stream.rb
148
162
  - lib/stationery/pdf/types.rb
149
163
  - lib/stationery/pdf/writer.rb
164
+ - lib/stationery/pdf/xmp.rb
150
165
  - lib/stationery/preview.rb
151
166
  - lib/stationery/rails.rb
152
167
  - lib/stationery/rails/previews_controller.rb
@@ -162,11 +177,13 @@ files:
162
177
  - lib/stationery/structure.rb
163
178
  - lib/stationery/svg/arc.rb
164
179
  - lib/stationery/svg/bounds.rb
180
+ - lib/stationery/svg/clip_path.rb
165
181
  - lib/stationery/svg/document.rb
166
182
  - lib/stationery/svg/gradient.rb
167
183
  - lib/stationery/svg/painter.rb
168
184
  - lib/stationery/svg/parser.rb
169
185
  - lib/stationery/svg/path_data.rb
186
+ - lib/stationery/svg/region.rb
170
187
  - lib/stationery/svg/selector.rb
171
188
  - lib/stationery/svg/shading.rb
172
189
  - lib/stationery/svg/shapes.rb
@@ -174,6 +191,8 @@ files:
174
191
  - lib/stationery/svg/stylesheet.rb
175
192
  - lib/stationery/svg/text.rb
176
193
  - lib/stationery/svg/transform.rb
194
+ - lib/stationery/svg/viewport.rb
195
+ - lib/stationery/svg/walker.rb
177
196
  - lib/stationery/tagging/element.rb
178
197
  - lib/stationery/tagging/tree.rb
179
198
  - lib/stationery/tagging/writer.rb