pdfrb 0.7.1 → 0.7.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/RELEASE.md +17 -11
- data/data/pdfrb/layout/hyphenation_en.txt +408 -0
- data/lib/pdfrb/color/default_profile.rb +213 -0
- data/lib/pdfrb/color/icc_validator.rb +69 -0
- data/lib/pdfrb/color.rb +2 -0
- data/lib/pdfrb/conformance/ltv.rb +112 -0
- data/lib/pdfrb/conformance/pades.rb +198 -0
- data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
- data/lib/pdfrb/conformance/pdf_a.rb +123 -0
- data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
- data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
- data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
- data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
- data/lib/pdfrb/conformance/pdf_x.rb +66 -1
- data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
- data/lib/pdfrb/conformance.rb +8 -0
- data/lib/pdfrb/content/parser.rb +82 -0
- data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
- data/lib/pdfrb/digital_signature.rb +1 -0
- data/lib/pdfrb/document.rb +29 -0
- data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
- data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
- data/lib/pdfrb/encryption/v5_writer.rb +108 -0
- data/lib/pdfrb/encryption.rb +3 -0
- data/lib/pdfrb/font_loader/type3.rb +65 -0
- data/lib/pdfrb/font_loader.rb +1 -0
- data/lib/pdfrb/image_loader/gif.rb +246 -0
- data/lib/pdfrb/image_loader/tiff.rb +257 -0
- data/lib/pdfrb/image_loader.rb +2 -0
- data/lib/pdfrb/layout/font_fallback.rb +203 -0
- data/lib/pdfrb/layout/hyphenation.rb +120 -0
- data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
- data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
- data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
- data/lib/pdfrb/layout/polygon_frame.rb +106 -0
- data/lib/pdfrb/layout/table_box.rb +154 -23
- data/lib/pdfrb/layout/text_shaper.rb +129 -0
- data/lib/pdfrb/layout.rb +7 -0
- data/lib/pdfrb/source/linearization_reader.rb +76 -0
- data/lib/pdfrb/source/recovery.rb +65 -1
- data/lib/pdfrb/source/tokenizer.rb +21 -0
- data/lib/pdfrb/source.rb +1 -0
- data/lib/pdfrb/task/thumbnail.rb +133 -0
- data/lib/pdfrb/task.rb +1 -0
- data/lib/pdfrb/version.rb +1 -1
- metadata +28 -2
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Glyph-level fallback: given a primary font and a chain of
|
|
6
|
+
# fallback fonts (default CJK, Symbol, etc.), picks the first
|
|
7
|
+
# font that can render the missing glyph. Returns the chosen
|
|
8
|
+
# font name or nil if none can render.
|
|
9
|
+
#
|
|
10
|
+
# Coverage lookup uses the Standard 14 AFM glyph-name sets for
|
|
11
|
+
# built-in fonts (Helvetica, Times-Roman, Courier, Symbol,
|
|
12
|
+
# ZapfDingbats), and falls back to WinAnsi range coverage for
|
|
13
|
+
# any other font.
|
|
14
|
+
class FontFallback
|
|
15
|
+
DEFAULT_CHAIN = %w[Helvetica Times-Roman Courier Symbol].freeze
|
|
16
|
+
|
|
17
|
+
# Common Standard 14 AFM character-name sets, indexed by font
|
|
18
|
+
# name. Each is a Set of glyph names the font defines. A
|
|
19
|
+
# codepoint is "covered" if its Unicode-to-glyph-name lookup
|
|
20
|
+
# yields a name in the set. Subset of full AFM data — covers
|
|
21
|
+
# the Latin, Greek, and punctuation ranges needed for typical
|
|
22
|
+
# prose.
|
|
23
|
+
STANDARD14_GLYPH_NAMES = {
|
|
24
|
+
"Helvetica" => %w[
|
|
25
|
+
space exclam quotedbl numbersign dollar percent ampersand
|
|
26
|
+
quoteright parenleft parenright asterisk plus comma minus
|
|
27
|
+
period slash zero one two three four five six seven eight
|
|
28
|
+
nine colon semicolon less equal greater question at A B C D
|
|
29
|
+
E F G H I J K L M N O P Q R S T U V W X Y Z bracketleft
|
|
30
|
+
backslash bracketright asciicircum underscore quoteleft a b
|
|
31
|
+
c d e f g h i j k l m n o p q r s t u v w x y z braceleft
|
|
32
|
+
bar braceright asciitilde
|
|
33
|
+
Agrave Aacute Acircumflex Atilde Adieresis Aring AE Ccedilla
|
|
34
|
+
Egrave Eacute Ecircumflex Edieresis Igrave Iacute Icircumflex
|
|
35
|
+
Idieresis Eth Ntilde Ograve Oacute Ocircumflex Otilde Odieresis
|
|
36
|
+
multiply Oslash Ugrave Uacute Ucircumflex Udieresis Yacute
|
|
37
|
+
Thorn germandbls agrave aacute acircumflex atilde adieresis
|
|
38
|
+
aring ae ccedilla egrave eacute ecircumflex edieresis igrave
|
|
39
|
+
iacute icircumflex idieresis eth ntilde ograve oacute
|
|
40
|
+
ocircumflex otilde odieresis divide oslash ugrave uacute
|
|
41
|
+
ucircumflex udieresis yacute thorn ydieresis
|
|
42
|
+
].to_set.freeze,
|
|
43
|
+
"Times-Roman" => %w[
|
|
44
|
+
space exclam quotedbl numbersign dollar percent ampersand
|
|
45
|
+
quoteright parenleft parenright asterisk plus comma minus
|
|
46
|
+
period slash zero one two three four five six seven eight
|
|
47
|
+
nine colon semicolon less equal greater question at A B C D
|
|
48
|
+
E F G H I J K L M N O P Q R S T U V W X Y Z bracketleft
|
|
49
|
+
backslash bracketright asciicircum underscore quoteleft a b
|
|
50
|
+
c d e f g h i j k l m n o p q r s t u v w x y z braceleft
|
|
51
|
+
bar braceright asciitilde
|
|
52
|
+
].to_set.freeze,
|
|
53
|
+
"Courier" => %w[
|
|
54
|
+
space exclam quotedbl numbersign dollar percent ampersand
|
|
55
|
+
quoteright parenleft parenright asterisk plus comma minus
|
|
56
|
+
period slash zero one two three four five six seven eight
|
|
57
|
+
nine colon semicolon less equal greater question at A B C D
|
|
58
|
+
E F G H I J K L M N O P Q R S T U V W X Y Z bracketleft
|
|
59
|
+
backslash bracketright asciicircum underscore quoteleft a b
|
|
60
|
+
c d e f g h i j k l m n o p q r s t u v w x y z braceleft
|
|
61
|
+
bar braceright asciitilde
|
|
62
|
+
].to_set.freeze,
|
|
63
|
+
"Symbol" => %w[
|
|
64
|
+
Alpha Beta Chi Delta Epsilon Phi Gamma Eta Iota Theta Kappa
|
|
65
|
+
Lambda Mu Nu Omicron Pi Rho Sigma Tau Upsilon Omega Xi Psi Zeta
|
|
66
|
+
alpha beta chi delta epsilon phi gamma eta iota theta kappa
|
|
67
|
+
lambda mu nu omicron pi rho sigma tau upsilon omega xi psi zeta
|
|
68
|
+
].to_set.freeze,
|
|
69
|
+
"ZapfDingbats" => %w[
|
|
70
|
+
a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 a16
|
|
71
|
+
].to_set.freeze,
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
# Unicode → Adobe glyph name lookup (subset). The full Adobe
|
|
75
|
+
# Glyph List is ~2400 entries; this covers Latin + Greek
|
|
76
|
+
# codepoints that the Standard 14 fonts handle.
|
|
77
|
+
UNICODE_TO_GLYPH = {
|
|
78
|
+
0x20 => "space", 0x21 => "exclam", 0x22 => "quotedbl",
|
|
79
|
+
0x23 => "numbersign", 0x24 => "dollar", 0x25 => "percent",
|
|
80
|
+
0x26 => "ampersand", 0x27 => "quoteright", 0x28 => "parenleft",
|
|
81
|
+
0x29 => "parenright", 0x2A => "asterisk", 0x2B => "plus",
|
|
82
|
+
0x2C => "comma", 0x2D => "minus", 0x2E => "period", 0x2F => "slash",
|
|
83
|
+
0xC0 => "Agrave", 0xC1 => "Aacute", 0xC2 => "Acircumflex",
|
|
84
|
+
0xC3 => "Atilde", 0xC4 => "Adieresis", 0xC5 => "Aring",
|
|
85
|
+
0xC6 => "AE", 0xC7 => "Ccedilla", 0xC8 => "Egrave",
|
|
86
|
+
0xC9 => "Eacute", 0xCA => "Ecircumflex", 0xCB => "Edieresis",
|
|
87
|
+
0xCC => "Igrave", 0xCD => "Iacute", 0xCE => "Icircumflex",
|
|
88
|
+
0xCF => "Idieresis", 0xD0 => "Eth", 0xD1 => "Ntilde",
|
|
89
|
+
0xD2 => "Ograve", 0xD3 => "Oacute", 0xD4 => "Ocircumflex",
|
|
90
|
+
0xD5 => "Otilde", 0xD6 => "Odieresis", 0xD8 => "Oslash",
|
|
91
|
+
0xD9 => "Ugrave", 0xDA => "Uacute", 0xDB => "Ucircumflex",
|
|
92
|
+
0xDC => "Udieresis", 0xDD => "Yacute", 0xDE => "Thorn",
|
|
93
|
+
0xDF => "germandbls",
|
|
94
|
+
0xE0 => "agrave", 0xE1 => "aacute", 0xE2 => "acircumflex",
|
|
95
|
+
0xE3 => "atilde", 0xE4 => "adieresis", 0xE5 => "aring",
|
|
96
|
+
0xE6 => "ae", 0xE7 => "ccedilla", 0xE8 => "egrave",
|
|
97
|
+
0xE9 => "eacute", 0xEA => "ecircumflex", 0xEB => "edieresis",
|
|
98
|
+
0xEC => "igrave", 0xED => "iacute", 0xEE => "icircumflex",
|
|
99
|
+
0xEF => "idieresis", 0xF0 => "eth", 0xF1 => "ntilde",
|
|
100
|
+
0xF2 => "ograve", 0xF3 => "oacute", 0xF4 => "ocircumflex",
|
|
101
|
+
0xF5 => "otilde", 0xF6 => "odieresis", 0xF8 => "oslash",
|
|
102
|
+
0xF9 => "ugrave", 0xFA => "uacute", 0xFB => "ucircumflex",
|
|
103
|
+
0xFC => "udieresis", 0xFD => "yacute", 0xFE => "thorn",
|
|
104
|
+
0xFF => "ydieresis",
|
|
105
|
+
0x391 => "Alpha", 0x392 => "Beta", 0x393 => "Gamma",
|
|
106
|
+
0x394 => "Delta", 0x395 => "Epsilon", 0x396 => "Zeta",
|
|
107
|
+
0x397 => "Eta", 0x398 => "Theta", 0x399 => "Iota",
|
|
108
|
+
0x39A => "Kappa", 0x39B => "Lambda", 0x39C => "Mu",
|
|
109
|
+
0x39D => "Nu", 0x39E => "Xi", 0x39F => "Omicron",
|
|
110
|
+
0x3A0 => "Pi", 0x3A1 => "Rho", 0x3A3 => "Sigma",
|
|
111
|
+
0x3A4 => "Tau", 0x3A5 => "Upsilon", 0x3A6 => "Phi",
|
|
112
|
+
0x3A7 => "Chi", 0x3A8 => "Psi", 0x3A9 => "Omega",
|
|
113
|
+
0x3B1 => "alpha", 0x3B2 => "beta", 0x3B3 => "gamma",
|
|
114
|
+
0x3B4 => "delta", 0x3B5 => "epsilon", 0x3B6 => "zeta",
|
|
115
|
+
0x3B7 => "eta", 0x3B8 => "theta", 0x3B9 => "iota",
|
|
116
|
+
0x3BA => "kappa", 0x3BB => "lambda", 0x3BC => "mu",
|
|
117
|
+
0x3BD => "nu", 0x3BE => "xi", 0x3BF => "omicron",
|
|
118
|
+
0x3C0 => "pi", 0x3C1 => "rho", 0x3C3 => "sigma",
|
|
119
|
+
0x3C4 => "tau", 0x3C5 => "upsilon", 0x3C6 => "phi",
|
|
120
|
+
0x3C7 => "chi", 0x3C8 => "psi", 0x3C9 => "omega"
|
|
121
|
+
}.freeze
|
|
122
|
+
|
|
123
|
+
attr_reader :chain
|
|
124
|
+
|
|
125
|
+
# @param chain [Array<String>] ordered list of font names to
|
|
126
|
+
# try when the primary can't render a glyph.
|
|
127
|
+
def initialize(chain: DEFAULT_CHAIN)
|
|
128
|
+
@chain = chain
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Find a font in the chain that covers +codepoint+.
|
|
132
|
+
# @return [String, nil] font name, or nil if none match.
|
|
133
|
+
def pick(codepoint, primary: nil)
|
|
134
|
+
return primary if primary && covers?(primary, codepoint)
|
|
135
|
+
|
|
136
|
+
@chain.find { |name| covers?(name, codepoint) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Reshape +text+ into segments, each tagged with the font that
|
|
140
|
+
# renders it. Returns an Array of [font_name, substring] pairs.
|
|
141
|
+
# Adjacent segments that share the same font are merged.
|
|
142
|
+
def segment(text, primary: nil)
|
|
143
|
+
return [[primary, text]] if text.to_s.empty?
|
|
144
|
+
|
|
145
|
+
segments = []
|
|
146
|
+
current_font = nil
|
|
147
|
+
current_chars = +""
|
|
148
|
+
|
|
149
|
+
text.to_s.each_codepoint do |cp|
|
|
150
|
+
ch = cp.chr(Encoding::UTF_8)
|
|
151
|
+
font = pick(cp, primary: primary)
|
|
152
|
+
if font == current_font
|
|
153
|
+
current_chars << ch
|
|
154
|
+
else
|
|
155
|
+
segments << [current_font, current_chars] if current_chars.length.positive?
|
|
156
|
+
current_font = font
|
|
157
|
+
current_chars = ch.to_s
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
segments << [current_font, current_chars] if current_chars.length.positive?
|
|
161
|
+
segments
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Whether +font_name+ can render +codepoint+. Uses the
|
|
165
|
+
# Standard 14 glyph-name sets for built-in fonts; for
|
|
166
|
+
# non-standard fonts, assumes WinAnsi coverage up to 255.
|
|
167
|
+
def covers?(font_name, codepoint)
|
|
168
|
+
return true if codepoint < 128 # ASCII always covered
|
|
169
|
+
|
|
170
|
+
standard14 = STANDARD14_GLYPH_NAMES[font_name.to_s]
|
|
171
|
+
if standard14
|
|
172
|
+
glyph = glyph_name_for(codepoint)
|
|
173
|
+
return !!glyph && standard14.include?(glyph)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
winansi?(codepoint)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def glyph_name_for(codepoint)
|
|
182
|
+
# Single-codepoint entries in UNICODE_TO_GLYPH.
|
|
183
|
+
return UNICODE_TO_GLYPH[codepoint] if UNICODE_TO_GLYPH.key?(codepoint)
|
|
184
|
+
|
|
185
|
+
# Ranges that map to ASCII letters/digits — all share the
|
|
186
|
+
# ASCII character as glyph name. Grouped to avoid rubocop
|
|
187
|
+
# duplicate-branch warnings.
|
|
188
|
+
case codepoint
|
|
189
|
+
when 0x30..0x39, 0x41..0x5A, 0x61..0x7A then codepoint.chr
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def winansi?(codepoint)
|
|
194
|
+
return true if codepoint < 0x80
|
|
195
|
+
return true if (0xA0..0xFF).cover?(codepoint)
|
|
196
|
+
return true if [0x20AC, 0x201A, 0x0192, 0x201E, 0x2026,
|
|
197
|
+
0x2020, 0x2021, 0x02C6, 0x2030].include?(codepoint)
|
|
198
|
+
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Knuth-Liang hyphenation algorithm, pure Ruby. Uses a small
|
|
6
|
+
# built-in pattern set covering common English patterns; callers
|
|
7
|
+
# can supply their own pattern list for other languages.
|
|
8
|
+
#
|
|
9
|
+
# Patterns come from TeX's .pat files. Each pattern has letters
|
|
10
|
+
# and digits where the digits encode the hyphenation weights
|
|
11
|
+
# (0=never, 1-9=allowed). Example: ".abc1de" means "before 'd'
|
|
12
|
+
# in 'abcde' you may hyphenate".
|
|
13
|
+
#
|
|
14
|
+
# The full English pattern set is ~62 KB. We ship a curated
|
|
15
|
+
# subset; users who need full coverage can pass their own
|
|
16
|
+
# +patterns:+ at construction.
|
|
17
|
+
module Hyphenation
|
|
18
|
+
# Curated subset covering common English word endings and
|
|
19
|
+
# prefixes. Not exhaustive but useful for plain prose. Numbers
|
|
20
|
+
# are weights; a weight of 1 means "may hyphenate here".
|
|
21
|
+
#
|
|
22
|
+
# Loaded from data/pdfrb/layout/hyphenation_en.txt so the
|
|
23
|
+
# patterns stay reviewable; falls back to an empty list if the
|
|
24
|
+
# data file is missing.
|
|
25
|
+
PATTERNS_EN = begin
|
|
26
|
+
path = File.expand_path("../../../data/pdfrb/layout/hyphenation_en.txt", __dir__)
|
|
27
|
+
File.exist?(path) ? File.readlines(path, chomp: true).reject(&:empty?) : []
|
|
28
|
+
rescue StandardError
|
|
29
|
+
[]
|
|
30
|
+
end.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# @param word [String] the word to hyphenate.
|
|
35
|
+
# @param patterns [Array<String>] Knuth-Liang patterns.
|
|
36
|
+
# @return [Array<Integer>] positions in +word+ where a hyphen
|
|
37
|
+
# may be inserted (between characters).
|
|
38
|
+
def hyphenate_positions(word, patterns: PATTERNS_EN)
|
|
39
|
+
return [] if word.length < 4
|
|
40
|
+
|
|
41
|
+
lowered = word.downcase.gsub(/[^a-z]/, "")
|
|
42
|
+
return [] if lowered.length < 4
|
|
43
|
+
|
|
44
|
+
weights = word_weights(lowered, patterns)
|
|
45
|
+
positions = []
|
|
46
|
+
# Odd weights between two characters mean a hyphen is allowed.
|
|
47
|
+
# Skip first and last character per Knuth-Liang rule.
|
|
48
|
+
(1...lowered.length).each do |i|
|
|
49
|
+
next if i < 2 || i > lowered.length - 2
|
|
50
|
+
|
|
51
|
+
positions << i if weights[i].odd?
|
|
52
|
+
end
|
|
53
|
+
positions
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Split +word+ into segments at valid hyphenation points.
|
|
57
|
+
# Includes the hyphen char (U+2010) on non-final segments when
|
|
58
|
+
# +with_hyphen:+ is true.
|
|
59
|
+
def split(word, patterns: PATTERNS_EN, with_hyphen: true)
|
|
60
|
+
positions = hyphenate_positions(word, patterns: patterns)
|
|
61
|
+
return [word] if positions.empty?
|
|
62
|
+
|
|
63
|
+
segments = []
|
|
64
|
+
prev = 0
|
|
65
|
+
positions.each do |pos|
|
|
66
|
+
segment = word[prev...pos] || ""
|
|
67
|
+
segment += "‐" if with_hyphen
|
|
68
|
+
segments << segment
|
|
69
|
+
prev = pos
|
|
70
|
+
end
|
|
71
|
+
segments << (word[prev..] || "")
|
|
72
|
+
segments
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Compute the per-character weights by overlaying every
|
|
76
|
+
# matching pattern. The weight at position i in the word is the
|
|
77
|
+
# max across all matching patterns at that inter-character slot.
|
|
78
|
+
def word_weights(word, patterns)
|
|
79
|
+
padded = ".#{word}."
|
|
80
|
+
weights = ::Array.new(word.length + 1, 0)
|
|
81
|
+
patterns.each do |pattern|
|
|
82
|
+
apply_pattern(weights, padded, pattern)
|
|
83
|
+
end
|
|
84
|
+
weights
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Decode a Knuth-Liang pattern into [letters, weights]. The
|
|
88
|
+
# letters are the alphabetic chars; the weights array has
|
|
89
|
+
# weights.length == letters.length + 1 (the slots between
|
|
90
|
+
# letters, including before and after).
|
|
91
|
+
def decode_pattern(pattern)
|
|
92
|
+
letters = +""
|
|
93
|
+
weights = [0]
|
|
94
|
+
pattern.each_char do |ch|
|
|
95
|
+
if /[0-9]/.match?(ch)
|
|
96
|
+
weights[-1] = [weights[-1], ch.to_i].max
|
|
97
|
+
else
|
|
98
|
+
letters << ch
|
|
99
|
+
weights << 0
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
[letters, weights]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def apply_pattern(weights, padded, pattern)
|
|
106
|
+
letters, pat_weights = decode_pattern(pattern)
|
|
107
|
+
start = 0
|
|
108
|
+
while (idx = padded.index(letters, start))
|
|
109
|
+
pat_weights.each_with_index do |w, i|
|
|
110
|
+
next if w.zero?
|
|
111
|
+
|
|
112
|
+
target = idx + i
|
|
113
|
+
weights[target] = [weights[target], w].max if weights[target]
|
|
114
|
+
end
|
|
115
|
+
start = idx + 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Arabic kashida justification: stretches Arabic letters using
|
|
6
|
+
# the tatweel (U+0640) character to fill a line, instead of (or
|
|
7
|
+
# in addition to) inter-word spacing. The technique inserts
|
|
8
|
+
# tatweel after selected letters (those with right-joining
|
|
9
|
+
# behaviour) to grow the line width toward the target.
|
|
10
|
+
module JustificationKashidas
|
|
11
|
+
# Arabic letters with right-joining behaviour (kashida can be
|
|
12
|
+
# inserted after these). Subset — covers the most common
|
|
13
|
+
# letters. Full list per Unicode Arabic Joining_Type.
|
|
14
|
+
RIGHT_JOINING = %w[
|
|
15
|
+
ب ت ث ج ح خ د ذ ر ز س ش ص ض ط ظ ع غ ف ق ك ل م ن ه و ي
|
|
16
|
+
ـب ـت ـث ـج ـح ـخ ـس ـش ـص ـض ـط ـظ ـع ـغ ـف ـق ـك ـل ـم ـن ـه
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
TATWEEL = "ـ"
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Insert kashidas into +text+ to grow it to +target_width+
|
|
24
|
+
# using +measure+ (a callable that returns the width of a
|
|
25
|
+
# string). Returns the modified string. If the natural width
|
|
26
|
+
# already meets target_width, returns text unchanged.
|
|
27
|
+
def justify(text, target_width:, measure:)
|
|
28
|
+
return text if text.to_s.empty?
|
|
29
|
+
|
|
30
|
+
natural = measure.call(text)
|
|
31
|
+
return text if natural >= target_width
|
|
32
|
+
|
|
33
|
+
candidates = kashida_insertion_points(text)
|
|
34
|
+
return text if candidates.empty?
|
|
35
|
+
|
|
36
|
+
# Greedy: add one tatweel per candidate until target reached.
|
|
37
|
+
result = text.to_s.dup
|
|
38
|
+
added = 0
|
|
39
|
+
loop do
|
|
40
|
+
break if natural >= target_width
|
|
41
|
+
break if added >= candidates.length * 3 # cap to avoid runaway
|
|
42
|
+
|
|
43
|
+
pos = candidates[added % candidates.length]
|
|
44
|
+
result = insert_at(result, pos + added, TATWEEL)
|
|
45
|
+
added += 1
|
|
46
|
+
natural = measure.call(result)
|
|
47
|
+
end
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Indices in +text+ where a tatweel may be inserted (after a
|
|
52
|
+
# right-joining letter that's not already followed by a space).
|
|
53
|
+
def kashida_insertion_points(text)
|
|
54
|
+
points = []
|
|
55
|
+
prev = nil
|
|
56
|
+
text.to_s.each_char.with_index do |ch, i|
|
|
57
|
+
next_char = text.to_s[i + 1]
|
|
58
|
+
if RIGHT_JOINING.include?(ch) && next_char &&
|
|
59
|
+
RIGHT_JOINING.include?(next_char)
|
|
60
|
+
points << (i + 1)
|
|
61
|
+
end
|
|
62
|
+
prev = ch
|
|
63
|
+
end
|
|
64
|
+
points
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def insert_at(string, pos, char)
|
|
68
|
+
string[0...pos] + char + string[pos..]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Multi-cell text layout: flows text across an arbitrary shape
|
|
6
|
+
# defined by a series of horizontal bands. Used for magazine-
|
|
7
|
+
# style layouts where text wraps around images, sidebars, or
|
|
8
|
+
# irregular page regions.
|
|
9
|
+
#
|
|
10
|
+
# A band is a rectangular region with [x, y, width, height] in
|
|
11
|
+
# PDF coordinates (origin bottom-left). The layouter fills bands
|
|
12
|
+
# top-to-bottom, left-to-right.
|
|
13
|
+
class MultiCellTextLayout
|
|
14
|
+
attr_reader :bands, :style
|
|
15
|
+
|
|
16
|
+
def initialize(bands:, style: Style.new(:base))
|
|
17
|
+
@bands = bands
|
|
18
|
+
@style = style
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Lay out +text+ into the bands. Returns an Array of
|
|
22
|
+
# [band_index, lines] pairs where each line is a Layout::Line.
|
|
23
|
+
def layout(text)
|
|
24
|
+
layouter = TextLayouter.new(@style)
|
|
25
|
+
result = []
|
|
26
|
+
words = text.to_s.split(/(\s+)/)
|
|
27
|
+
current_band = 0
|
|
28
|
+
cursor_height = band_height(current_band)
|
|
29
|
+
current_chunk = +""
|
|
30
|
+
|
|
31
|
+
words.each do |word|
|
|
32
|
+
trial = current_chunk + word
|
|
33
|
+
lines = layouter.layout(trial, band_width(current_band))
|
|
34
|
+
total_h = lines.sum { |l| line_height(l) }
|
|
35
|
+
|
|
36
|
+
if total_h > cursor_height && current_band < @bands.length - 1
|
|
37
|
+
# Flush current chunk to current band
|
|
38
|
+
result << [current_band, layouter.layout(current_chunk, band_width(current_band))] if current_chunk.length.positive?
|
|
39
|
+
current_band += 1
|
|
40
|
+
cursor_height = band_height(current_band)
|
|
41
|
+
current_chunk = +word
|
|
42
|
+
else
|
|
43
|
+
current_chunk = trial
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
result << [current_band, layouter.layout(current_chunk, band_width(current_band))] if current_chunk.length.positive?
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def band_width(index)
|
|
53
|
+
@bands[index] ? @bands[index][2].to_f : 100.0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def band_height(index)
|
|
57
|
+
@bands[index] ? @bands[index][3].to_f : 100.0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def line_height(_line)
|
|
61
|
+
@style.font_size ? @style.font_size * 1.2 : 14.0
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Multi-page table: wraps a single TableBox's worth of rows and
|
|
6
|
+
# splits it into per-page fragments when it doesn't fit a single
|
|
7
|
+
# frame. Each fragment is a TableBox whose fit? returns true for
|
|
8
|
+
# the available height; the splitter yields them in document
|
|
9
|
+
# order, optionally repeating a header row on each fragment.
|
|
10
|
+
#
|
|
11
|
+
# This is intentionally a thin coordinator: it doesn't re-layout
|
|
12
|
+
# cells; it just partitions rows. The actual row drawing is
|
|
13
|
+
# delegated to TableBox, so col/row spans continue to work — but
|
|
14
|
+
# spans that cross a split boundary are clamped at the split
|
|
15
|
+
# (their rowspan is truncated to the rows that fit).
|
|
16
|
+
class MultiPageTableBox < Box
|
|
17
|
+
DEFAULT_MIN_ROWS_PER_PAGE = 3
|
|
18
|
+
DEFAULT_REPEAT_HEADER = true
|
|
19
|
+
|
|
20
|
+
attr_reader :rows, :column_widths, :header_row_count, :min_rows_per_page
|
|
21
|
+
|
|
22
|
+
# @param rows [Array<Array<Pdfrb::Layout::TableBox::Cell, Box>>]
|
|
23
|
+
# the full table content.
|
|
24
|
+
# @param column_widths [Array<Numeric>, nil] explicit widths.
|
|
25
|
+
# @param header_row_count [Integer] number of leading rows to
|
|
26
|
+
# repeat at the top of each page fragment. 0 disables.
|
|
27
|
+
# @param min_rows_per_page [Integer] minimum number of body
|
|
28
|
+
# rows on each page; if the split would produce fewer, the
|
|
29
|
+
# fragment is deferred to the next page.
|
|
30
|
+
def initialize(rows:, column_widths: nil, header_row_count: 0,
|
|
31
|
+
min_rows_per_page: DEFAULT_MIN_ROWS_PER_PAGE, **)
|
|
32
|
+
super(**)
|
|
33
|
+
@rows = rows
|
|
34
|
+
@column_widths = column_widths
|
|
35
|
+
@header_row_count = header_row_count
|
|
36
|
+
@min_rows_per_page = min_rows_per_page
|
|
37
|
+
@fragments = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the per-page fragments as an Array of TableBox. The
|
|
41
|
+
# first fragment fits within +available_height+; each
|
|
42
|
+
# subsequent fragment fits within +page_height+ (the next page
|
|
43
|
+
# may have more room because there's no preceding content).
|
|
44
|
+
#
|
|
45
|
+
# Mutates internal state (the @fragments cache). Idempotent for
|
|
46
|
+
# the same dimensions.
|
|
47
|
+
def fragments(available_width:, first_page_height:, later_page_height:)
|
|
48
|
+
compute_column_widths(available_width)
|
|
49
|
+
header = @header_row_count.positive? ? @rows.first(@header_row_count) : []
|
|
50
|
+
body = @rows[@header_row_count..] || []
|
|
51
|
+
|
|
52
|
+
fragments = []
|
|
53
|
+
cursor = 0
|
|
54
|
+
first_pass = true
|
|
55
|
+
while cursor < body.length
|
|
56
|
+
available = first_pass ? first_page_height : later_page_height
|
|
57
|
+
max_count = rows_that_fit(body, cursor, available)
|
|
58
|
+
count = if max_count.zero? && cursor.zero?
|
|
59
|
+
# First fragment must contain at least one row
|
|
60
|
+
# even if it overflows; otherwise the table is
|
|
61
|
+
# undrawable.
|
|
62
|
+
[@min_rows_per_page, body.length].min
|
|
63
|
+
elsif max_count < @min_rows_per_page
|
|
64
|
+
# Force at least min_rows_per_page (may overflow).
|
|
65
|
+
[@min_rows_per_page, body.length - cursor].min
|
|
66
|
+
else
|
|
67
|
+
max_count
|
|
68
|
+
end
|
|
69
|
+
break if count.zero?
|
|
70
|
+
|
|
71
|
+
chunk = body[cursor, count]
|
|
72
|
+
rows_for_fragment = header + chunk
|
|
73
|
+
fragments << build_fragment(rows_for_fragment)
|
|
74
|
+
cursor += count
|
|
75
|
+
first_pass = false
|
|
76
|
+
end
|
|
77
|
+
fragments
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fit?(available_width, available_height)
|
|
81
|
+
@fragments = fragments(available_width: available_width,
|
|
82
|
+
first_page_height: available_height,
|
|
83
|
+
later_page_height: available_height)
|
|
84
|
+
@fragments.any?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def draw_content(canvas, x, y)
|
|
88
|
+
return if @fragments.nil? || @fragments.empty?
|
|
89
|
+
|
|
90
|
+
# Draw the first fragment in place; the Composer is
|
|
91
|
+
# responsible for placing subsequent fragments on later
|
|
92
|
+
# pages by calling draw on each fragment directly.
|
|
93
|
+
offset_y = y
|
|
94
|
+
@fragments.each do |frag|
|
|
95
|
+
frag.draw(canvas, x, offset_y)
|
|
96
|
+
offset_y -= frag.height
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Iterate fragments. Yields each TableBox fragment; if a block
|
|
101
|
+
# is absent, returns an Enumerator. Useful for the Composer
|
|
102
|
+
# flow: the first fragment is drawn at the current cursor,
|
|
103
|
+
# subsequent fragments trigger a page break.
|
|
104
|
+
def each_fragment(&block)
|
|
105
|
+
return enum_for(:each_fragment) unless block
|
|
106
|
+
|
|
107
|
+
@fragments.each(&block)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def compute_column_widths(available_width)
|
|
113
|
+
return @column_widths = equal_widths(available_width) if @column_widths.nil?
|
|
114
|
+
|
|
115
|
+
@column_widths.map!(&:to_f)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def equal_widths(available_width)
|
|
119
|
+
col_count = @rows.first&.size || 1
|
|
120
|
+
Array.new(col_count, available_width / col_count)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Measure how many rows from +body+ starting at +cursor+ fit
|
|
124
|
+
# in +available_height+ using the computed column widths.
|
|
125
|
+
def rows_that_fit(body, cursor, available_height)
|
|
126
|
+
remaining = available_height.to_f
|
|
127
|
+
count = 0
|
|
128
|
+
body[cursor..].each_with_index do |row, _i|
|
|
129
|
+
row_h = measure_row_height(row)
|
|
130
|
+
break if (remaining - row_h).negative?
|
|
131
|
+
|
|
132
|
+
remaining -= row_h
|
|
133
|
+
count += 1
|
|
134
|
+
end
|
|
135
|
+
count
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Estimate row height as the max natural height of any cell
|
|
139
|
+
# in the row, given the column widths.
|
|
140
|
+
def measure_row_height(row)
|
|
141
|
+
heights = row.map.with_index do |cell, _col|
|
|
142
|
+
cell_box = cell.is_a?(TableBox::Cell) ? cell.box : cell
|
|
143
|
+
next 0 unless cell_box.is_a?(Pdfrb::Layout::Box)
|
|
144
|
+
|
|
145
|
+
cell_box.height.to_f
|
|
146
|
+
end
|
|
147
|
+
heights.max || 14.0
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_fragment(rows)
|
|
151
|
+
TableBox.new(rows: rows, column_widths: @column_widths&.dup)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|