pdfrb 0.6.0 → 0.7.10
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 +144 -0
- data/RELEASE.md +52 -33
- data/data/pdfrb/afm/Courier-Bold.afm +342 -342
- data/data/pdfrb/afm/Courier-BoldOblique.afm +342 -342
- data/data/pdfrb/afm/Courier-Oblique.afm +342 -342
- data/data/pdfrb/afm/Courier.afm +342 -342
- data/data/pdfrb/afm/Helvetica-Bold.afm +2827 -2827
- data/data/pdfrb/afm/Helvetica-BoldOblique.afm +2827 -2827
- data/data/pdfrb/afm/Helvetica-Oblique.afm +3051 -3051
- data/data/pdfrb/afm/Helvetica.afm +3051 -3051
- data/data/pdfrb/afm/Symbol.afm +213 -213
- data/data/pdfrb/afm/Times-Bold.afm +2588 -2588
- data/data/pdfrb/afm/Times-BoldItalic.afm +2384 -2384
- data/data/pdfrb/afm/Times-Italic.afm +2667 -2667
- data/data/pdfrb/afm/Times-Roman.afm +2419 -2419
- data/data/pdfrb/afm/ZapfDingbats.afm +225 -225
- 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/canvas.rb +137 -0
- data/lib/pdfrb/content/operators/inline_image.rb +53 -0
- data/lib/pdfrb/content/operators.rb +1 -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/png.rb +76 -19
- 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/model/type/page_piece_info.rb +58 -0
- data/lib/pdfrb/model/type.rb +3 -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/regenerate_appearances.rb +98 -0
- data/lib/pdfrb/task/thumbnail.rb +133 -0
- data/lib/pdfrb/task.rb +2 -0
- data/lib/pdfrb/version.rb +1 -1
- metadata +35 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Polygon-aware Frame: extends Frame to compute available area
|
|
6
|
+
# against an arbitrary polygon shape, not just a rectangle.
|
|
7
|
+
# Useful for L-shaped text regions, text-around-image, and
|
|
8
|
+
# irregular page layouts.
|
|
9
|
+
#
|
|
10
|
+
# The default Frame.find_available_area always returns the
|
|
11
|
+
# top-left rectangle; this subclass finds the largest inscribed
|
|
12
|
+
# rectangle of (width × height) inside the polygon, scanning
|
|
13
|
+
# row-by-row to find the leftmost x where the requested box fits
|
|
14
|
+
# at the requested height.
|
|
15
|
+
class PolygonFrame < Frame
|
|
16
|
+
DEFAULT_STEP = 1.0
|
|
17
|
+
|
|
18
|
+
attr_reader :polygon
|
|
19
|
+
|
|
20
|
+
# @param polygon [Array<Array<Numeric>>] array of [x, y] vertex
|
|
21
|
+
# pairs. The polygon is implicitly closed.
|
|
22
|
+
# @param step [Numeric] scan resolution in PDF units; smaller
|
|
23
|
+
# steps find tighter fits but take longer.
|
|
24
|
+
def initialize(left:, bottom:, width:, height:, polygon: nil, step: DEFAULT_STEP)
|
|
25
|
+
super(left: left, bottom: bottom, width: width, height: height)
|
|
26
|
+
@polygon = polygon || [[left, bottom], [left + width, bottom],
|
|
27
|
+
[left + width, bottom + height],
|
|
28
|
+
[left, bottom + height]]
|
|
29
|
+
@step = step.to_f
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Find the next available area inside the polygon that fits
|
|
33
|
+
# (width, height). Walks row by row from the top of the
|
|
34
|
+
# bounding box downward; at each row y, scans x from left
|
|
35
|
+
# toward the right edge, returning the first (x, y, w, h)
|
|
36
|
+
# position where all four corners of (x, y - h, w, h) are
|
|
37
|
+
# inside the polygon.
|
|
38
|
+
def find_available_area(width, height)
|
|
39
|
+
w = width.to_f
|
|
40
|
+
h = height.to_f
|
|
41
|
+
return nil unless w.positive? && h.positive?
|
|
42
|
+
|
|
43
|
+
bbox_top = bounding_box_top
|
|
44
|
+
bbox_bottom = bounding_box_bottom
|
|
45
|
+
bbox_left = bounding_box_left
|
|
46
|
+
bbox_right = bounding_box_right
|
|
47
|
+
|
|
48
|
+
y = bbox_top
|
|
49
|
+
while y - h >= bbox_bottom
|
|
50
|
+
x = bbox_left
|
|
51
|
+
while x + w <= bbox_right
|
|
52
|
+
return [x, y, w, h] if contains_rectangle?(x, y - h, w, h)
|
|
53
|
+
|
|
54
|
+
x += @step
|
|
55
|
+
end
|
|
56
|
+
y -= @step
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bounding_box_top
|
|
62
|
+
@polygon.map { |_x, y| y }.max.to_f
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bounding_box_bottom
|
|
66
|
+
@polygon.map { |_x, y| y }.min.to_f
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def bounding_box_left
|
|
70
|
+
@polygon.map { |x, _y| x }.min.to_f
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bounding_box_right
|
|
74
|
+
@polygon.map { |x, _y| x }.max.to_f
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Point-in-polygon test (ray casting).
|
|
78
|
+
def contains_point?(x, y)
|
|
79
|
+
inside = false
|
|
80
|
+
n = @polygon.length
|
|
81
|
+
i = 0
|
|
82
|
+
j = n - 1
|
|
83
|
+
while i < n
|
|
84
|
+
xi, yi = @polygon[i]
|
|
85
|
+
xj, yj = @polygon[j]
|
|
86
|
+
if ((yi > y) != (yj > y)) &&
|
|
87
|
+
(x < ((xj - xi) * (y - yi) / (yj - yi)) + xi)
|
|
88
|
+
inside = !inside
|
|
89
|
+
end
|
|
90
|
+
j = i
|
|
91
|
+
i += 1
|
|
92
|
+
end
|
|
93
|
+
inside
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def contains_rectangle?(x, y, w, h)
|
|
97
|
+
[
|
|
98
|
+
[x, y],
|
|
99
|
+
[x + w, y],
|
|
100
|
+
[x, y + h],
|
|
101
|
+
[x + w, y + h],
|
|
102
|
+
].all? { |px, py| contains_point?(px, py) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -1,47 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string literal: true
|
|
2
2
|
|
|
3
3
|
module Pdfrb
|
|
4
4
|
module Layout
|
|
5
5
|
# A table of cells. Each cell holds a Box. Column widths are auto
|
|
6
6
|
# unless specified; row heights computed from content.
|
|
7
|
+
#
|
|
8
|
+
# Cells may declare +colspan+ / +rowspan+ to occupy multiple
|
|
9
|
+
# columns or rows. Cell entries can be plain Boxes (treated as
|
|
10
|
+
# 1x1 cells) or +TableBox::Cell+ instances created via
|
|
11
|
+
# +TableBox.cell(box, colspan:, rowspan:+).
|
|
7
12
|
class TableBox < Box
|
|
13
|
+
Cell = Struct.new(:box, :colspan, :rowspan, keyword_init: true) do
|
|
14
|
+
def initialize(box:, colspan: 1, rowspan: 1)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fit?(*args); box.fit?(*args); end
|
|
19
|
+
|
|
20
|
+
def draw(*args); box.draw(*args); end
|
|
21
|
+
|
|
22
|
+
def height; box.height; end
|
|
23
|
+
|
|
24
|
+
def width; box.width; end
|
|
25
|
+
end
|
|
26
|
+
|
|
8
27
|
attr_reader :rows, :column_widths
|
|
9
28
|
|
|
29
|
+
# Build a Cell wrapping +box+ with the given spans.
|
|
30
|
+
def self.cell(box, colspan: 1, rowspan: 1)
|
|
31
|
+
Cell.new(box: box, colspan: colspan, rowspan: rowspan)
|
|
32
|
+
end
|
|
33
|
+
|
|
10
34
|
def initialize(rows:, column_widths: nil, **)
|
|
11
35
|
super(**)
|
|
12
|
-
@rows = rows
|
|
36
|
+
@rows = rows.map { |row| row.map { |c| wrap_cell(c) } }
|
|
13
37
|
@column_widths = column_widths
|
|
14
38
|
end
|
|
15
39
|
|
|
16
40
|
def fit?(available_width, available_height)
|
|
17
41
|
compute_column_widths(available_width)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
row.each_with_index do |cell, col|
|
|
22
|
-
cell_w = @column_widths[col]
|
|
23
|
-
cell.fit?(cell_w, remaining) or return false
|
|
24
|
-
row_heights << cell.height
|
|
25
|
-
end
|
|
26
|
-
row_h = row_heights.max || 0
|
|
27
|
-
remaining -= row_h
|
|
28
|
-
end
|
|
29
|
-
@width = available_width
|
|
30
|
-
@height = available_height - remaining
|
|
31
|
-
true
|
|
42
|
+
layout_grid!
|
|
43
|
+
fit_rows!(available_height)
|
|
44
|
+
@height <= available_height
|
|
32
45
|
end
|
|
33
46
|
|
|
34
47
|
def draw_content(canvas, x, y)
|
|
35
48
|
offset_y = y
|
|
36
|
-
|
|
37
|
-
max_h = 0
|
|
49
|
+
row_heights.each_with_index do |row_h, row_index|
|
|
38
50
|
offset_x = x
|
|
39
|
-
|
|
40
|
-
cell
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
@column_widths.each_with_index do |_w, col_index|
|
|
52
|
+
cell = cell_at(row_index, col_index)
|
|
53
|
+
if cell && origin?(row_index, col_index, cell)
|
|
54
|
+
span_w = spanned_width(col_index, cell.colspan)
|
|
55
|
+
span_h = spanned_height(row_index, cell.rowspan)
|
|
56
|
+
cell.box.fit?(span_w, span_h)
|
|
57
|
+
cell.draw(canvas, offset_x, offset_y)
|
|
58
|
+
end
|
|
59
|
+
offset_x += @column_widths[col_index]
|
|
43
60
|
end
|
|
44
|
-
offset_y -=
|
|
61
|
+
offset_y -= row_h
|
|
45
62
|
end
|
|
46
63
|
end
|
|
47
64
|
|
|
@@ -49,8 +66,23 @@ module Pdfrb
|
|
|
49
66
|
@rows.empty?
|
|
50
67
|
end
|
|
51
68
|
|
|
69
|
+
# Height of each row, indexed by row position. Populated by
|
|
70
|
+
# +fit?+.
|
|
71
|
+
def row_heights
|
|
72
|
+
@row_heights || []
|
|
73
|
+
end
|
|
74
|
+
|
|
52
75
|
private
|
|
53
76
|
|
|
77
|
+
def wrap_cell(value)
|
|
78
|
+
return value if value.is_a?(Cell)
|
|
79
|
+
|
|
80
|
+
box = value.is_a?(::Hash) ? value[:box] : value
|
|
81
|
+
colspan = value.is_a?(::Hash) ? (value[:colspan] || 1) : 1
|
|
82
|
+
rowspan = value.is_a?(::Hash) ? (value[:rowspan] || 1) : 1
|
|
83
|
+
Cell.new(box: box, colspan: colspan, rowspan: rowspan)
|
|
84
|
+
end
|
|
85
|
+
|
|
54
86
|
def compute_column_widths(available_width)
|
|
55
87
|
return @column_widths = equal_widths(available_width) if @column_widths.nil?
|
|
56
88
|
|
|
@@ -61,6 +93,105 @@ module Pdfrb
|
|
|
61
93
|
col_count = @rows.first&.size || 1
|
|
62
94
|
Array.new(col_count, available_width / col_count)
|
|
63
95
|
end
|
|
96
|
+
|
|
97
|
+
# Build a (row, col) -> Cell map accounting for spans, so any
|
|
98
|
+
# cell covered by a span from above or to the left resolves to
|
|
99
|
+
# the same Cell instance. @grid[r][c] = the originating Cell.
|
|
100
|
+
def layout_grid!
|
|
101
|
+
@grid = ::Array.new(@rows.size) { ::Array.new(column_count) }
|
|
102
|
+
@origins = {} # [r, c] -> true if this is the top-left of a Cell
|
|
103
|
+
@rows.each_with_index do |row, r|
|
|
104
|
+
c = 0
|
|
105
|
+
row.each do |cell|
|
|
106
|
+
c = next_free_column(r, c)
|
|
107
|
+
place_cell(cell, r, c)
|
|
108
|
+
@origins[[r, c]] = true
|
|
109
|
+
c += cell.colspan
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def next_free_column(row, col)
|
|
115
|
+
col += 1 while @grid[row] && @grid[row][col]
|
|
116
|
+
col
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def place_cell(cell, row, col)
|
|
120
|
+
cell.rowspan.times do |dr|
|
|
121
|
+
cell.colspan.times do |dc|
|
|
122
|
+
r = row + dr
|
|
123
|
+
c = col + dc
|
|
124
|
+
next unless @grid[r] && c < @grid[r].size
|
|
125
|
+
|
|
126
|
+
@grid[r][c] = cell
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def column_count
|
|
132
|
+
@column_widths.length
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cell_at(row, col)
|
|
136
|
+
@grid[row]&.[](col)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def origin?(row, col, cell)
|
|
140
|
+
# The cell is the origin if the grid position matches the
|
|
141
|
+
# cell's top-left (i.e. the position above and to the left
|
|
142
|
+
# belongs to a different cell or none).
|
|
143
|
+
return true if row.zero? && col.zero?
|
|
144
|
+
|
|
145
|
+
above = row.positive? ? @grid[row - 1][col] : nil
|
|
146
|
+
left = col.positive? ? @grid[row][col - 1] : nil
|
|
147
|
+
above != cell && left != cell
|
|
148
|
+
end
|
|
149
|
+
alias is_origin? origin?
|
|
150
|
+
|
|
151
|
+
def spanned_width(start_col, colspan)
|
|
152
|
+
span = colspan
|
|
153
|
+
@column_widths[start_col, span].sum
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def spanned_height(start_row, rowspan)
|
|
157
|
+
span = rowspan
|
|
158
|
+
@row_heights[start_row, span]&.sum || 0
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def fit_rows!(available_height)
|
|
162
|
+
@row_heights = compute_row_heights(available_height)
|
|
163
|
+
@height = @row_heights.sum
|
|
164
|
+
@width = @column_widths.sum
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# First pass: compute the natural height of each row by taking
|
|
169
|
+
# the max content height across cells in that row, ignoring
|
|
170
|
+
# rowspan contributions to non-origin rows.
|
|
171
|
+
def compute_row_heights(available_height)
|
|
172
|
+
heights = ::Array.new(@rows.size, 0)
|
|
173
|
+
@rows.each_with_index do |row, r|
|
|
174
|
+
row.each_with_index do |_cell, _c|
|
|
175
|
+
# Handled via grid below for span correctness.
|
|
176
|
+
end
|
|
177
|
+
# Use the grid to skip span-occupied cells.
|
|
178
|
+
column_count.times do |c|
|
|
179
|
+
cell = @grid[r][c]
|
|
180
|
+
next unless cell && origin?(r, c, cell)
|
|
181
|
+
|
|
182
|
+
span_w = spanned_width(c, cell.colspan)
|
|
183
|
+
cell.fit?(span_w, available_height)
|
|
184
|
+
cell_h = cell.height.to_f
|
|
185
|
+
# Distribute the height across the rows the cell spans.
|
|
186
|
+
distribute = [(cell_h / cell.rowspan).ceil, 1].max
|
|
187
|
+
cell.rowspan.times do |dr|
|
|
188
|
+
rr = r + dr
|
|
189
|
+
heights[rr] = [heights[rr], distribute].max if heights[rr]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
heights
|
|
194
|
+
end
|
|
64
195
|
end
|
|
65
196
|
end
|
|
66
197
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Layout
|
|
5
|
+
# Text shaping interface. Pure-Ruby shapers don't exist for the
|
|
6
|
+
# complexity of OpenType GSUB/GPOS — this module defines the
|
|
7
|
+
# contract that a real shaper (HarfBuzz, fribidi, etc.) must
|
|
8
|
+
# satisfy. Callers can plug in a shaper implementation via
|
|
9
|
+
# Pdfrb::Layout::TextShaper.implementation = MyShaper.
|
|
10
|
+
#
|
|
11
|
+
# The default implementation is grapheme-cluster-aware: it keeps
|
|
12
|
+
# each user-perceived character (a base + combining marks, a
|
|
13
|
+
# Hangul syllable, an emoji-ZWJ sequence, etc.) together as one
|
|
14
|
+
# cluster so the TextLayouter measures and breaks at cluster
|
|
15
|
+
# boundaries, never inside one.
|
|
16
|
+
module TextShaper
|
|
17
|
+
@implementation = nil
|
|
18
|
+
|
|
19
|
+
ShapedRun = Struct.new(:codepoints, :clusters, :advances,
|
|
20
|
+
:cluster_starts, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
attr_accessor :implementation
|
|
24
|
+
|
|
25
|
+
# Shape +text+ for +font+ at +size+. Returns a ShapedRun.
|
|
26
|
+
#
|
|
27
|
+
# When an +implementation+ is registered, delegates to it.
|
|
28
|
+
# Otherwise runs the default grapheme-cluster shaper.
|
|
29
|
+
def shape(text, font: nil, size: 12, direction: :ltr)
|
|
30
|
+
if implementation
|
|
31
|
+
return implementation.shape(text, font: font, size: size,
|
|
32
|
+
direction: direction)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
default_shape(text, size, direction)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Default shaper: one cluster per grapheme. Each cluster's
|
|
41
|
+
# advance is its base-codepoint width (combining marks add
|
|
42
|
+
# zero). Falls back to size/2 when font metrics aren't
|
|
43
|
+
# available.
|
|
44
|
+
def default_shape(text, size, _direction)
|
|
45
|
+
clusters = text.to_s.grapheme_clusters.to_a
|
|
46
|
+
codepoints = []
|
|
47
|
+
cluster_indices = []
|
|
48
|
+
advances = []
|
|
49
|
+
cluster_starts = []
|
|
50
|
+
|
|
51
|
+
clusters.each_with_index do |cluster, cluster_idx|
|
|
52
|
+
cluster_starts << codepoints.length
|
|
53
|
+
cps = cluster.codepoints.to_a
|
|
54
|
+
cps.each do |cp|
|
|
55
|
+
codepoints << cp
|
|
56
|
+
cluster_indices << cluster_idx
|
|
57
|
+
advances << advance_for(cp, size, cps)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ShapedRun.new(
|
|
62
|
+
codepoints: codepoints,
|
|
63
|
+
clusters: cluster_indices,
|
|
64
|
+
advances: advances,
|
|
65
|
+
cluster_starts: cluster_starts
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Per-codepoint advance. Combining marks (Unicode general
|
|
70
|
+
# category M*) contribute 0; everything else gets size/2
|
|
71
|
+
# as a heuristic unless real metrics are available.
|
|
72
|
+
def advance_for(codepoint, size, _cluster_cps)
|
|
73
|
+
return 0.0 if combining_mark?(codepoint)
|
|
74
|
+
return size * 0.6 if wide?(codepoint)
|
|
75
|
+
|
|
76
|
+
# Heuristic average advance for ASCII range.
|
|
77
|
+
size / 2.0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Combining marks: Unicode general categories Mn, Mc, Me.
|
|
81
|
+
# Subset of common ranges; the full list is in
|
|
82
|
+
# UnicodeData.txt.
|
|
83
|
+
def combining_mark?(codepoint)
|
|
84
|
+
ranges = [
|
|
85
|
+
0x0300..0x036F, # Combining Diacritical Marks
|
|
86
|
+
0x0483..0x0489,
|
|
87
|
+
0x0591..0x05BD,
|
|
88
|
+
0x05BF..0x05BF,
|
|
89
|
+
0x05C1..0x05C2, 0x05C4..0x05C5, 0x05C7..0x05C7,
|
|
90
|
+
0x0610..0x061A,
|
|
91
|
+
0x064B..0x065F,
|
|
92
|
+
0x0670..0x0670,
|
|
93
|
+
0x06D6..0x06DC,
|
|
94
|
+
0x06DF..0x06E4,
|
|
95
|
+
0x06E7..0x06E8,
|
|
96
|
+
0x06EA..0x06ED,
|
|
97
|
+
0x0711..0x0711,
|
|
98
|
+
0x0730..0x074A,
|
|
99
|
+
0x0900..0x0903,
|
|
100
|
+
0x093A..0x094F,
|
|
101
|
+
0x0951..0x0957,
|
|
102
|
+
0x20D0..0x20FF, # Combining marks for symbols
|
|
103
|
+
0xFE00..0xFE0F # Variation selectors
|
|
104
|
+
]
|
|
105
|
+
ranges.any? { |r| r.cover?(codepoint) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Wide characters: CJK + emoji. These typically take 1em
|
|
109
|
+
# advance (double-width of a Latin letter).
|
|
110
|
+
def wide?(codepoint)
|
|
111
|
+
ranges = [
|
|
112
|
+
0x1100..0x115F, # Hangul Jamo
|
|
113
|
+
0x2E80..0x303E, # CJK Radicals
|
|
114
|
+
0x3040..0x33BF, # Hiragana, Katakana, CJK
|
|
115
|
+
0x3400..0x4DBF, # CJK Extension A
|
|
116
|
+
0x4E00..0x9FFF, # CJK Unified Ideographs
|
|
117
|
+
0xA000..0xA4CF, # Yi
|
|
118
|
+
0xAC00..0xD7A3, # Hangul Syllables
|
|
119
|
+
0xF900..0xFAFF, # CJK Compatibility Ideographs
|
|
120
|
+
0xFE30..0xFE4F, # CJK Compatibility Forms
|
|
121
|
+
0x1F000..0x1FAFF, # Emoji + extensions
|
|
122
|
+
0x20000..0x2FFFF, # CJK Extension B+
|
|
123
|
+
]
|
|
124
|
+
ranges.any? { |r| r.cover?(codepoint) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/pdfrb/layout.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Pdfrb
|
|
|
15
15
|
autoload :ImageBox, "pdfrb/layout/image_box"
|
|
16
16
|
autoload :ListBox, "pdfrb/layout/list_box"
|
|
17
17
|
autoload :TableBox, "pdfrb/layout/table_box"
|
|
18
|
+
autoload :MultiPageTableBox, "pdfrb/layout/multi_page_table_box"
|
|
18
19
|
autoload :ColumnBox, "pdfrb/layout/column_box"
|
|
19
20
|
autoload :InlineBox, "pdfrb/layout/inline_box"
|
|
20
21
|
autoload :BoxFitter, "pdfrb/layout/box_fitter"
|
|
@@ -23,6 +24,12 @@ module Pdfrb
|
|
|
23
24
|
autoload :TextFragment, "pdfrb/layout/text_fragment"
|
|
24
25
|
autoload :Line, "pdfrb/layout/line"
|
|
25
26
|
autoload :Bidi, "pdfrb/layout/bidi"
|
|
27
|
+
autoload :Hyphenation, "pdfrb/layout/hyphenation"
|
|
28
|
+
autoload :TextShaper, "pdfrb/layout/text_shaper"
|
|
29
|
+
autoload :PolygonFrame, "pdfrb/layout/polygon_frame"
|
|
30
|
+
autoload :FontFallback, "pdfrb/layout/font_fallback"
|
|
31
|
+
autoload :JustificationKashidas, "pdfrb/layout/justification_kashidas"
|
|
32
|
+
autoload :MultiCellTextLayout, "pdfrb/layout/multi_cell_text_layout"
|
|
26
33
|
autoload :RomanNumeral, "pdfrb/layout/list_box"
|
|
27
34
|
end
|
|
28
35
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Model
|
|
5
|
+
module Type
|
|
6
|
+
# Page-piece dictionary (s14.5). Per-page metadata recording
|
|
7
|
+
# application-specific private data. The dictionary hangs off
|
|
8
|
+
# a page's /PieceInfo key and is keyed by application name;
|
|
9
|
+
# each value carries /LastModified, /Private, and (optionally)
|
|
10
|
+
# an /ASFStream reference for a stream-encoded private payload.
|
|
11
|
+
#
|
|
12
|
+
# Conforming readers MUST NOT interpret application-private
|
|
13
|
+
# data; it survives round-trips and incremental updates intact.
|
|
14
|
+
class PagePieceInfo < Pdfrb::Model::Cos::Dictionary
|
|
15
|
+
# The application-name key (e.g. :Adobe, :Pdfrb). Multiple
|
|
16
|
+
# applications may each have their own entry in the same
|
|
17
|
+
# /PieceInfo dict.
|
|
18
|
+
def application_name
|
|
19
|
+
value.key?(:Application) ? value[:Application] : nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# /LastModified — PDF date string, parsed to a Time.
|
|
23
|
+
def last_modified
|
|
24
|
+
v = value[:LastModified]
|
|
25
|
+
return nil unless v.is_a?(::String)
|
|
26
|
+
|
|
27
|
+
Pdfrb::Model::Date.parse(v)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# /Private — application-private dict. Untyped on purpose:
|
|
31
|
+
# the contents are app-defined.
|
|
32
|
+
def private_data
|
|
33
|
+
value[:Private]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Set the application's /LastModified to +time+. +time+ is
|
|
37
|
+
# converted to a PDF date string.
|
|
38
|
+
def last_modified=(time)
|
|
39
|
+
value[:LastModified] = Pdfrb::Model::Date.format(time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Merge +hash+ into /Private. If /Private is absent, it is
|
|
43
|
+
# created from the hash.
|
|
44
|
+
def merge_private!(hash)
|
|
45
|
+
existing = value[:Private]
|
|
46
|
+
if existing.is_a?(Pdfrb::Model::Cos::Dictionary)
|
|
47
|
+
existing.value.merge!(hash)
|
|
48
|
+
elsif existing.is_a?(::Hash)
|
|
49
|
+
existing.merge!(hash)
|
|
50
|
+
else
|
|
51
|
+
value[:Private] = hash
|
|
52
|
+
end
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/pdfrb/model/type.rb
CHANGED
|
@@ -353,6 +353,9 @@ module Pdfrb
|
|
|
353
353
|
# Structure types (s14.7).
|
|
354
354
|
autoload :StructureAttributes, "pdfrb/model/type/structure_attributes"
|
|
355
355
|
autoload :StructureElementKid, "pdfrb/model/type/structure_element_kid"
|
|
356
|
+
|
|
357
|
+
# Page-piece dictionary (s14.5).
|
|
358
|
+
autoload :PagePieceInfo, "pdfrb/model/type/page_piece_info"
|
|
356
359
|
end
|
|
357
360
|
end
|
|
358
361
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Source
|
|
5
|
+
# Reads the Linearization dictionary (Annex F) that lives on the
|
|
6
|
+
# first indirect object of a linearized PDF. Exposes the
|
|
7
|
+
# page-one fast-path offset (/O), end of first page (/E), number
|
|
8
|
+
# of pages (/N), and the hint stream offset (/T) so callers can
|
|
9
|
+
# resolve page 1 without parsing the rest of the file.
|
|
10
|
+
#
|
|
11
|
+
# A non-linearized PDF returns nil from .detect.
|
|
12
|
+
class LinearizationReader
|
|
13
|
+
LinearizationInfo = Struct.new(
|
|
14
|
+
:linearized, # 1.0 marker
|
|
15
|
+
:file_length, # /L
|
|
16
|
+
:first_page_obj_offset, # /O
|
|
17
|
+
:first_page_end_offset, # /E
|
|
18
|
+
:page_count, # /N
|
|
19
|
+
:hint_stream_offset, # /T
|
|
20
|
+
:primary_hint_offset, # /H [offset, length]
|
|
21
|
+
keyword_init: true
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Parse the first indirect object at offset 0 in +io+ and
|
|
26
|
+
# return a LinearizationInfo if it's a Linearization dict,
|
|
27
|
+
# nil otherwise.
|
|
28
|
+
def detect(io)
|
|
29
|
+
io.seek(0, IO::SEEK_SET)
|
|
30
|
+
header = io.read(1024).to_s
|
|
31
|
+
return nil unless header.start_with?("%PDF-")
|
|
32
|
+
|
|
33
|
+
# Skip the header line, then look for the first "N G obj"
|
|
34
|
+
# marker. The Linearization dict is on the first indirect
|
|
35
|
+
# object in a linearized file.
|
|
36
|
+
io.seek(0, IO::SEEK_SET)
|
|
37
|
+
tok = Pdfrb::Source::Tokenizer.new(io)
|
|
38
|
+
parser = Pdfrb::Source::Parser.new(tok, document: nil)
|
|
39
|
+
first_obj = begin
|
|
40
|
+
parser.parse_indirect_object
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
return nil unless first_obj
|
|
45
|
+
|
|
46
|
+
dict = dictionary_value(first_obj)
|
|
47
|
+
return nil unless dict && dict[:Linearized]
|
|
48
|
+
|
|
49
|
+
build_info(dict)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def dictionary_value(obj)
|
|
55
|
+
return obj.value if obj.is_a?(Pdfrb::Model::Cos::Dictionary)
|
|
56
|
+
return obj.value if obj.is_a?(Pdfrb::Model::Cos::Stream)
|
|
57
|
+
|
|
58
|
+
obj if obj.is_a?(::Hash)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_info(dict)
|
|
62
|
+
hint = dict[:H]
|
|
63
|
+
LinearizationInfo.new(
|
|
64
|
+
linearized: dict[:Linearized].to_s,
|
|
65
|
+
file_length: dict[:L]&.to_i,
|
|
66
|
+
first_page_obj_offset: dict[:O]&.to_i,
|
|
67
|
+
first_page_end_offset: dict[:E]&.to_i,
|
|
68
|
+
page_count: dict[:N]&.to_i,
|
|
69
|
+
hint_stream_offset: dict[:T]&.to_i,
|
|
70
|
+
primary_hint_offset: hint.is_a?(::Array) ? hint : nil
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|