stationery 0.9.0 → 0.10.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +146 -35
  4. data/lib/stationery/{svg → css}/selector.rb +1 -1
  5. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  6. data/lib/stationery/css.rb +20 -0
  7. data/lib/stationery/document.rb +21 -9
  8. data/lib/stationery/elements/rich.rb +10 -2
  9. data/lib/stationery/elements.rb +9 -4
  10. data/lib/stationery/errors.rb +4 -0
  11. data/lib/stationery/fonts/fallback.rb +1 -1
  12. data/lib/stationery/fonts/font.rb +5 -2
  13. data/lib/stationery/fonts/glyph_run.rb +35 -5
  14. data/lib/stationery/html/css.rb +276 -0
  15. data/lib/stationery/html/document.rb +5 -3
  16. data/lib/stationery/html/tokenizer.rb +15 -4
  17. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  18. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  19. data/lib/stationery/html/tree_builder.rb +11 -1
  20. data/lib/stationery/layout/flow.rb +29 -0
  21. data/lib/stationery/layout/image.rb +12 -6
  22. data/lib/stationery/layout/mark.rb +2 -0
  23. data/lib/stationery/layout/node.rb +4 -0
  24. data/lib/stationery/layout/text.rb +47 -10
  25. data/lib/stationery/pdf/assembler.rb +14 -4
  26. data/lib/stationery/pdf/signature/cms.rb +21 -13
  27. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  28. data/lib/stationery/pdf/signature.rb +16 -9
  29. data/lib/stationery/pdf/writer.rb +73 -19
  30. data/lib/stationery/rich/nodes.rb +37 -11
  31. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  32. data/lib/stationery/rich/renderer/indents.rb +1 -0
  33. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  34. data/lib/stationery/rich/renderer.rb +32 -15
  35. data/lib/stationery/svg/style.rb +4 -6
  36. data/lib/stationery/testing/inspector.rb +49 -6
  37. data/lib/stationery/testing/marked_text.rb +34 -4
  38. data/lib/stationery/text/breaks.rb +123 -0
  39. data/lib/stationery/text/paragraph.rb +8 -0
  40. data/lib/stationery/text/runs_builder.rb +4 -0
  41. data/lib/stationery/text/wrapper.rb +57 -8
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +9 -0
  44. data/lib/stationery.rb +3 -2
  45. metadata +8 -3
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Text
5
+ # Where a line may break inside a word: between two ideographic
6
+ # characters (UAX #14 class ID: CJK ideographs, kana, Hangul, fullwidth
7
+ # forms), never before a closing mark or a character that must not start
8
+ # a line (、。」ー and the small kana: classes CL, CP, EX and NS), never
9
+ # after an opening bracket (「(: class OP). A word is cut into units at
10
+ # those points; Latin text stays one unit, so its breaking is unchanged.
11
+ module Breaks
12
+ ZERO_WIDTH_SPACE = "​"
13
+
14
+ IDEOGRAPHIC = [
15
+ 0x1100..0x11FF, # Hangul Jamo
16
+ 0x2E80..0x2FDF, # CJK radicals
17
+ 0x3005..0x3007, # 々 〆 〇
18
+ 0x3021..0x3029, # Hangzhou numerals
19
+ 0x3031..0x3035, # kana repeat marks
20
+ 0x3038..0x303C,
21
+ 0x3041..0x309A, # Hiragana (the small kana are taken out below)
22
+ 0x30A1..0x30FA, # Katakana
23
+ 0x3100..0x312F, # Bopomofo
24
+ 0x3130..0x318F, # Hangul compatibility Jamo
25
+ 0x3190..0x31FF, # Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions
26
+ 0x3200..0x33FF, # enclosed CJK, CJK compatibility
27
+ 0x3400..0x4DBF, # CJK extension A
28
+ 0x4E00..0x9FFF, # CJK unified ideographs
29
+ 0xA000..0xA4CF, # Yi
30
+ 0xA960..0xA97F, # Hangul Jamo extended A
31
+ 0xAC00..0xD7AF, # Hangul syllables
32
+ 0xF900..0xFAFF, # CJK compatibility ideographs
33
+ 0xFE30..0xFE4F, # CJK compatibility forms
34
+ 0xFF10..0xFF19, # fullwidth digits
35
+ 0xFF21..0xFF3A, 0xFF41..0xFF5A, # fullwidth Latin
36
+ 0xFF66..0xFF6F, 0xFF71..0xFF9D, # halfwidth Katakana (ー and the small ones are NS)
37
+ 0x1B000..0x1B16F, # Kana supplement and extended
38
+ 0x20000..0x3134F # CJK extensions B–G
39
+ ].freeze
40
+
41
+ # Never at the start of a line: closing brackets and quotes, sentence
42
+ # and clause punctuation, prolonged sound and iteration marks, small kana.
43
+ CLOSING = [
44
+ 0x3001..0x3003, 0x3009, 0x300B, 0x300D, 0x300F, 0x3011, 0x3015, 0x3017, 0x3019, 0x301B, 0x301C,
45
+ 0x301F, 0x303B, 0x309B..0x309E, 0x30A0, 0x30FB..0x30FE,
46
+ 0x3041, 0x3043, 0x3045, 0x3047, 0x3049, 0x3063, 0x3083, 0x3085, 0x3087, 0x308E, 0x3095, 0x3096,
47
+ 0x30A1, 0x30A3, 0x30A5, 0x30A7, 0x30A9, 0x30C3, 0x30E3, 0x30E5, 0x30E7, 0x30EE, 0x30F5, 0x30F6,
48
+ 0x31F0..0x31FF,
49
+ 0xFE50..0xFE52, 0xFE54..0xFE57, 0xFE5A, 0xFE5C, 0xFE5E,
50
+ 0xFF01, 0xFF09, 0xFF0C, 0xFF0E, 0xFF1A, 0xFF1B, 0xFF1F, 0xFF3D, 0xFF5D, 0xFF60, 0xFF61, 0xFF63,
51
+ 0xFF64, 0xFF65, 0xFF67..0xFF6F, 0xFF70, 0xFF9E, 0xFF9F,
52
+ 0x2025, 0x2026
53
+ ].freeze
54
+
55
+ # Never at the end of a line: opening brackets and quotes.
56
+ OPENING = [
57
+ 0x3008, 0x300A, 0x300C, 0x300E, 0x3010, 0x3014, 0x3016, 0x3018, 0x301A, 0x301D,
58
+ 0xFE59, 0xFE5B, 0xFE5D,
59
+ 0xFF08, 0xFF3B, 0xFF5B, 0xFF5F, 0xFF62
60
+ ].freeze
61
+
62
+ CLOSING_CODES = CLOSING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
63
+ OPENING_CODES = OPENING.flat_map { |entry| Array(entry) }.to_h { |code| [code, true] }.freeze
64
+
65
+ module_function
66
+
67
+ def ideographic?(char)
68
+ code = char.ord
69
+ !CLOSING_CODES.key?(code) && IDEOGRAPHIC.any? { |range| range.cover?(code) }
70
+ end
71
+
72
+ def closing?(char) = CLOSING_CODES.key?(char.ord)
73
+ def opening?(char) = OPENING_CODES.key?(char.ord)
74
+
75
+ # Whether `text` holds any character these rules know about, the cheap
76
+ # test that keeps Latin text on the fast path.
77
+ def cjk?(text) = text.match?(CJK)
78
+
79
+ CJK = /[ᄀ-ᇿ⺀-⿟、-ヿ㄀-鿿ꀀ-꓏ꥠ-꥿가-힯豈-﫿︰-﹞!-゚\u{1B000}-\u{1B16F}\u{20000}-\u{3134F}‥…]/
80
+
81
+ # The word's break units in order, each `[text, glued]`: `glued` is true
82
+ # for a unit that must stay with the unit before it (it starts with a
83
+ # closing mark), so the caller joins it to whatever came before, even
84
+ # across styled runs. A word without CJK characters is one unit.
85
+ def units(word)
86
+ return [[word, false]] unless cjk?(word)
87
+
88
+ units = []
89
+ current = +""
90
+ glued = false
91
+ open = false # current holds opening brackets only, waiting for what they open
92
+ word.each_char do |char|
93
+ if closing?(char)
94
+ glued = true if current.empty?
95
+ elsif starts_unit?(char, current, open)
96
+ units << [current.freeze, glued] unless current.empty?
97
+ current = +""
98
+ glued = false
99
+ end
100
+ current << char
101
+ open = opening?(char) && (open || current.length == 1)
102
+ end
103
+ units << [current.freeze, glued] unless current.empty?
104
+ units
105
+ end
106
+
107
+ # Whether `char` opens a new unit after `current`: an opening bracket or
108
+ # an ideographic character does unless `current` is only opening
109
+ # brackets; Latin text does after a unit that ends in a CJK character
110
+ # (an opening bracket or Latin followed by a closing mark stay glued).
111
+ def starts_unit?(char, current, open)
112
+ return false if current.empty? || open
113
+ return true if opening?(char) || ideographic?(char)
114
+
115
+ cjk?(current[-1]) && !(closing?(current[-1]) && !ideographic_unit?(current))
116
+ end
117
+
118
+ # A unit that carries an ideographic character (as opposed to Latin text
119
+ # followed by a closing mark, such as "abc。").
120
+ def ideographic_unit?(text) = text.each_char.any? { |char| ideographic?(char) }
121
+ end
122
+ end
123
+ end
@@ -34,6 +34,14 @@ module Stationery
34
34
  [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
35
35
  end
36
36
 
37
+ # [first `count` lines, the rest]; nil for an empty side.
38
+ def split_at(count)
39
+ return [nil, self] if count <= 0
40
+ return [self, nil] if count >= @lines.size
41
+
42
+ [with_lines(@lines.first(count)), with_lines(@lines.drop(count))]
43
+ end
44
+
37
45
  # A paragraph that fits `max_height`: truncated to whole lines, shrunk
38
46
  # (never below 4pt) or left as is for :visible.
39
47
  def fit(max_height, overflow:)
@@ -46,6 +46,10 @@ module Stationery
46
46
  def size(value, text = nil, &) = styled({ size: value }, text, &)
47
47
  def font(family, text = nil, &) = styled({ family: family.to_s }, text, &)
48
48
  def link(url, text = nil, &) = styled({ link: url.to_s }, text, &)
49
+ # The current size multiplied: `scale(1.5) { … }` is one and a half times the text around it.
50
+ def scale(factor, text = nil, &) = styled({ size: @styles.last.size * factor }, text, &)
51
+ # Any style fields at once, also to switch a mark off: `with({ weight: :regular }) { … }`.
52
+ def with(overrides, text = nil, &) = styled(overrides, text, &)
49
53
 
50
54
  private
51
55
 
@@ -3,7 +3,8 @@
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 that does not fit is
6
+ # dropped at the break), after hyphens, at a zero-width space (U+200B,
7
+ # HTML's <wbr>) and between ideographic characters (see Breaks); a word that does not fit is
7
8
  # hyphenated when it may be (a soft hyphen U+00AD names the break points,
8
9
  # else the style's `hyphenate` language), and a word wider than the line
9
10
  # is broken between characters. A word that changes style midway
@@ -12,6 +13,7 @@ module Stationery
12
13
  class Wrapper
13
14
  TOKEN = /\n|[ \t]+|[^ \t\n-]*-+|[^ \t\n-]+/
14
15
  SOFT_HYPHEN = "­"
16
+ ZERO_WIDTH_SPACE = Breaks::ZERO_WIDTH_SPACE
15
17
  EPSILON = 0.0001
16
18
 
17
19
  Segment = Data.define(:text, :style)
@@ -34,22 +36,67 @@ module Stationery
34
36
 
35
37
  private
36
38
 
37
- # [:word, segments] | [:space, segments] | [:newline]
39
+ # [:word, segments] | [:space, segments] | [:newline]. A word that runs
40
+ # on into the next styled run stays one word ("<b>Tot</b>al"); a
41
+ # zero-width space is an empty :space, a break that draws nothing; a
42
+ # word with CJK characters is cut into its break units (Breaks).
38
43
  def items(runs)
39
44
  items = []
45
+ @join = false # whether the next token may continue the last word
40
46
  runs.each do |run|
41
- run.text.scan(TOKEN) do |token|
42
- if token == "\n" then items << [:newline]
43
- elsif token.match?(/\A[ \t]/) then items << [:space, [Segment.new(token, run.style)]]
44
- elsif items.last&.first == :word && !items.last.last.last.text.end_with?("-")
45
- items.last.last << Segment.new(token, run.style)
46
- else items << [:word, [Segment.new(token, run.style)]]
47
+ if run.text.include?(ZERO_WIDTH_SPACE)
48
+ run.text.split(ZERO_WIDTH_SPACE, -1).each_with_index do |part, index|
49
+ if index.positive?
50
+ items << [:space, []]
51
+ @join = false
52
+ end
53
+ tokens(items, part, run.style)
47
54
  end
55
+ else
56
+ tokens(items, run.text, run.style)
48
57
  end
49
58
  end
50
59
  items
51
60
  end
52
61
 
62
+ def tokens(items, text, style)
63
+ text.scan(TOKEN) do |token|
64
+ if token == "\n"
65
+ items << [:newline]
66
+ @join = false
67
+ elsif token.match?(/\A[ \t]/)
68
+ items << [:space, [Segment.new(token, style)]]
69
+ @join = false
70
+ else
71
+ word_units(items, token, style)
72
+ end
73
+ end
74
+ end
75
+
76
+ # A Latin word is one unit and may run on into the next styled run
77
+ # unless it ends in a hyphen; a word with CJK characters is cut into
78
+ # its break units, none of which the next token continues. Latin words
79
+ # skip the unit split, so they cost no allocation of their own.
80
+ def word_units(items, token, style)
81
+ if Breaks.cjk?(token)
82
+ Breaks.units(token).each do |text, glued|
83
+ add_unit(items, text, style, glued)
84
+ @join = false
85
+ end
86
+ else
87
+ add_unit(items, token, style, false)
88
+ @join = !token.end_with?("-")
89
+ end
90
+ end
91
+
92
+ def add_unit(items, text, style, glued)
93
+ if (@join || glued) && items.last&.first == :word
94
+ items.last.last << Segment.new(text, style)
95
+ else
96
+ items << [:word, [Segment.new(text, style)]]
97
+ end
98
+ end
99
+
53
100
  def place((kind, segments))
54
101
  @ended_with_newline = kind == :newline
55
102
  case kind
@@ -115,6 +162,8 @@ module Stationery
115
162
  return (1...chars.length).select { |index| chars[index - 1].text == SOFT_HYPHEN } if @explicit
116
163
 
117
164
  text = chars.map(&:text).join
165
+ return [] if Breaks.cjk?(text)
166
+
118
167
  language = chars.first.style.hyphenate or return []
119
168
  core = text[/\p{L}+/] or return []
120
169
  offset = text.index(core)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.1"
5
5
  end
@@ -47,6 +47,15 @@ module Stationery
47
47
  def message = "content nested #{depth} levels deep was flattened below level #{limit}"
48
48
  end
49
49
 
50
+ UnsupportedCss = Data.define(:properties, :selectors) do
51
+ def message
52
+ parts = []
53
+ parts << "properties #{properties.join(", ")}" if properties.any?
54
+ parts << "selectors #{selectors.join(", ")}" if selectors.any?
55
+ "html styles not read: #{parts.join("; ")}"
56
+ end
57
+ end
58
+
50
59
  DuplicateAnchor = Data.define(:name, :page) do
51
60
  def message = %(anchor "#{name}" on page #{page} is already defined)
52
61
  end
data/lib/stationery.rb CHANGED
@@ -68,6 +68,7 @@ require_relative "stationery/pdf/conformance"
68
68
  require_relative "stationery/pdf/factur_x"
69
69
  require_relative "stationery/pdf/signature"
70
70
  require_relative "stationery/pdf/signature/cms"
71
+ require_relative "stationery/pdf/signature/timestamp"
71
72
  require_relative "stationery/forms/metrics"
72
73
  require_relative "stationery/forms/typeface"
73
74
  require_relative "stationery/forms/field"
@@ -82,6 +83,7 @@ require_relative "stationery/text/entities"
82
83
  require_relative "stationery/text/markup"
83
84
  require_relative "stationery/text/runs_builder"
84
85
  require_relative "stationery/text/line"
86
+ require_relative "stationery/text/breaks"
85
87
  require_relative "stationery/text/wrapper"
86
88
  require_relative "stationery/text/paragraph"
87
89
  require_relative "stationery/layout/node"
@@ -106,8 +108,7 @@ require_relative "stationery/svg/path_data"
106
108
  require_relative "stationery/svg/arc"
107
109
  require_relative "stationery/svg/parser"
108
110
  require_relative "stationery/svg/transform"
109
- require_relative "stationery/svg/selector"
110
- require_relative "stationery/svg/stylesheet"
111
+ require_relative "stationery/css"
111
112
  require_relative "stationery/svg/style"
112
113
  require_relative "stationery/svg/shapes"
113
114
  require_relative "stationery/svg/bounds"
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.9.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -40,6 +40,9 @@ files:
40
40
  - lib/stationery/cli/render.rb
41
41
  - lib/stationery/color.rb
42
42
  - lib/stationery/component.rb
43
+ - lib/stationery/css.rb
44
+ - lib/stationery/css/selector.rb
45
+ - lib/stationery/css/stylesheet.rb
43
46
  - lib/stationery/document.rb
44
47
  - lib/stationery/elements.rb
45
48
  - lib/stationery/elements/forms.rb
@@ -90,6 +93,7 @@ files:
90
93
  - lib/stationery/forms/metrics.rb
91
94
  - lib/stationery/forms/typeface.rb
92
95
  - lib/stationery/geometry.rb
96
+ - lib/stationery/html/css.rb
93
97
  - lib/stationery/html/document.rb
94
98
  - lib/stationery/html/tokenizer.rb
95
99
  - lib/stationery/html/tree_builder.rb
@@ -161,6 +165,7 @@ files:
161
165
  - lib/stationery/pdf/serializer.rb
162
166
  - lib/stationery/pdf/signature.rb
163
167
  - lib/stationery/pdf/signature/cms.rb
168
+ - lib/stationery/pdf/signature/timestamp.rb
164
169
  - lib/stationery/pdf/stream.rb
165
170
  - lib/stationery/pdf/types.rb
166
171
  - lib/stationery/pdf/writer.rb
@@ -174,6 +179,7 @@ files:
174
179
  - lib/stationery/rich/nesting.rb
175
180
  - lib/stationery/rich/nodes.rb
176
181
  - lib/stationery/rich/renderer.rb
182
+ - lib/stationery/rich/renderer/boxes.rb
177
183
  - lib/stationery/rich/renderer/indents.rb
178
184
  - lib/stationery/rich/renderer/inlines.rb
179
185
  - lib/stationery/rich/renderer/links.rb
@@ -189,11 +195,9 @@ files:
189
195
  - lib/stationery/svg/parser.rb
190
196
  - lib/stationery/svg/path_data.rb
191
197
  - lib/stationery/svg/region.rb
192
- - lib/stationery/svg/selector.rb
193
198
  - lib/stationery/svg/shading.rb
194
199
  - lib/stationery/svg/shapes.rb
195
200
  - lib/stationery/svg/style.rb
196
- - lib/stationery/svg/stylesheet.rb
197
201
  - lib/stationery/svg/text.rb
198
202
  - lib/stationery/svg/transform.rb
199
203
  - lib/stationery/svg/viewport.rb
@@ -205,6 +209,7 @@ files:
205
209
  - lib/stationery/testing/marked_text.rb
206
210
  - lib/stationery/testing/matchers.rb
207
211
  - lib/stationery/testing/structure_reader.rb
212
+ - lib/stationery/text/breaks.rb
208
213
  - lib/stationery/text/entities.rb
209
214
  - lib/stationery/text/entities/html4.rb
210
215
  - lib/stationery/text/line.rb