bidi2pdf 0.1.9 → 0.1.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 +59 -3
- data/README.md +146 -7
- data/lib/bidi2pdf/bidi/auth_interceptor.rb +3 -0
- data/lib/bidi2pdf/bidi/browser_tab.rb +38 -8
- data/lib/bidi2pdf/bidi/commands/cdp_get_session.rb +21 -0
- data/lib/bidi2pdf/bidi/commands/page_print.rb +101 -0
- data/lib/bidi2pdf/bidi/commands/print_parameters_validator.rb +4 -1
- data/lib/bidi2pdf/bidi/commands.rb +2 -0
- data/lib/bidi2pdf/bidi/connection_manager.rb +3 -0
- data/lib/bidi2pdf/bidi/session.rb +33 -2
- data/lib/bidi2pdf/cli.rb +9 -2
- data/lib/bidi2pdf/test_helpers/configuration.rb +67 -0
- data/lib/bidi2pdf/test_helpers/images/extractor.rb +99 -0
- data/lib/bidi2pdf/test_helpers/images/image_similarity_checker.rb +50 -0
- data/lib/bidi2pdf/test_helpers/images/tiff_helper.rb +204 -0
- data/lib/bidi2pdf/test_helpers/images.rb +12 -0
- data/lib/bidi2pdf/test_helpers/matchers/contains_pdf_image.rb +29 -0
- data/lib/bidi2pdf/test_helpers/pdf_file_helper.rb +39 -0
- data/lib/bidi2pdf/test_helpers/spec_paths_helper.rb +60 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +1 -1
- data/lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb +1 -1
- data/lib/bidi2pdf/test_helpers.rb +7 -0
- data/lib/bidi2pdf/version.rb +1 -1
- metadata +49 -5
data/lib/bidi2pdf/cli.rb
CHANGED
@@ -74,6 +74,8 @@ module Bidi2pdf
|
|
74
74
|
option :page_ranges, type: :array, desc: "Page ranges to print (e.g., 1-2 4 6)"
|
75
75
|
option :scale, type: :numeric, default: 1.0, desc: "Scale between 0.1 and 2.0"
|
76
76
|
option :shrink_to_fit, type: :boolean, default: true, desc: "Shrink content to fit page"
|
77
|
+
option :generate_tagged_pdf, type: :boolean, default: false, desc: "Generate tagged PDF"
|
78
|
+
option :generate_document_outline, type: :boolean, default: false, desc: "Generate document outline"
|
77
79
|
|
78
80
|
class << self
|
79
81
|
def exit_on_failure?
|
@@ -150,7 +152,7 @@ module Bidi2pdf
|
|
150
152
|
raise Thor::Error, "Invalid print option: #{e.message}"
|
151
153
|
end
|
152
154
|
|
153
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
155
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
154
156
|
def print_options
|
155
157
|
opts = {}
|
156
158
|
|
@@ -186,10 +188,15 @@ module Bidi2pdf
|
|
186
188
|
assign_if_provided(page, :height, :page_height)
|
187
189
|
opts[:page] = page unless page.empty?
|
188
190
|
|
191
|
+
assign_if_provided(opts, :generate_tagged_pdf)
|
192
|
+
assign_if_provided(opts, :generate_document_outline)
|
193
|
+
|
194
|
+
opts[:cmd_type] = :cdp if opts[:generate_tagged_pdf] || opts[:generate_document_outline]
|
195
|
+
|
189
196
|
opts.empty? ? nil : opts
|
190
197
|
end
|
191
198
|
|
192
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
199
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
193
200
|
|
194
201
|
def option_provided?(key)
|
195
202
|
ARGV.include?("--#{key.to_s.tr("_", "-")}") || ARGV.include?("--#{key}")
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module TestHelpers
|
5
|
+
class Configuration
|
6
|
+
# @!attribute [rw] spec_dir
|
7
|
+
# @return [Pathname] the directory where specs are located
|
8
|
+
attr_accessor :spec_dir
|
9
|
+
|
10
|
+
# @!attribute [rw] tmp_dir
|
11
|
+
# @return [String] the directory for temporary files
|
12
|
+
attr_accessor :tmp_dir
|
13
|
+
|
14
|
+
# @!attribute [rw] prefix
|
15
|
+
# @return [String] the prefix for temporary files
|
16
|
+
attr_accessor :prefix
|
17
|
+
|
18
|
+
# @!attribute [rw] docker_dir
|
19
|
+
# @return [String] the directory for Docker files
|
20
|
+
attr_accessor :docker_dir
|
21
|
+
|
22
|
+
# @!attribute [rw] fixture_dir
|
23
|
+
# @return [String] the directory for fixture files
|
24
|
+
attr_accessor :fixture_dir
|
25
|
+
|
26
|
+
def initialize
|
27
|
+
project_root = if defined?(Rails) && Rails.respond_to?(:root)
|
28
|
+
Pathname.new(Rails.root)
|
29
|
+
elsif defined?(Bundler) && Bundler.respond_to?(:root)
|
30
|
+
Pathname.new(Bundler.root)
|
31
|
+
else
|
32
|
+
Pathname.new(Dir.pwd)
|
33
|
+
end
|
34
|
+
|
35
|
+
@spec_dir = project_root.join("spec").expand_path
|
36
|
+
@docker_dir = project_root.join("docker")
|
37
|
+
@fixture_dir = project_root.join("spec", "fixtures")
|
38
|
+
@tmp_dir = project_root.join("tmp")
|
39
|
+
@prefix = "tmp_"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class << self
|
44
|
+
# Retrieves the current configuration object for TestHelpers.
|
45
|
+
# @return [Configuration] the configuration object
|
46
|
+
def configuration
|
47
|
+
@configuration ||= Configuration.new
|
48
|
+
end
|
49
|
+
|
50
|
+
# Sets the configuration object for TestHelpers.
|
51
|
+
# @param [Configuration] config the configuration object to set
|
52
|
+
attr_writer :configuration
|
53
|
+
|
54
|
+
# Allows configuration of TestHelpers by yielding the configuration object.
|
55
|
+
# @yieldparam [Configuration] configuration the configuration object to modify
|
56
|
+
def configure
|
57
|
+
yield(configuration)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Configures RSpec to include and extend SpecPathsHelper for examples with the `:pdf` metadata.
|
63
|
+
RSpec.configure do |config|
|
64
|
+
# Adds a custom RSpec setting for TestHelpers configuration.
|
65
|
+
config.add_setting :bidi2pdf_test_helpers_config, default: TestHelpers.configuration
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module TestHelpers
|
5
|
+
module Images
|
6
|
+
require "vips"
|
7
|
+
require "zlib"
|
8
|
+
|
9
|
+
class Extractor
|
10
|
+
include PDFReaderUtils
|
11
|
+
include TIFFHelper
|
12
|
+
|
13
|
+
attr_reader :pages, :logger
|
14
|
+
|
15
|
+
def initialize(pdf_data, logger: Bidi2pdf.logger)
|
16
|
+
reader = pdf_reader_for pdf_data
|
17
|
+
@pages = reader.pages
|
18
|
+
@logger = logger
|
19
|
+
end
|
20
|
+
|
21
|
+
def all_images
|
22
|
+
extracted_images.map { |images| images[:images] }.flatten
|
23
|
+
end
|
24
|
+
|
25
|
+
def image_on_page(page_number, image_number)
|
26
|
+
images = images_on_page(page_number)
|
27
|
+
return nil if images.empty? || image_number > images.size
|
28
|
+
|
29
|
+
images[image_number - 1]
|
30
|
+
end
|
31
|
+
|
32
|
+
def images_on_page(page_number)
|
33
|
+
extracted_images.find { |images| images[:page] == page_number }&.dig(:images) || []
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def extracted_images
|
39
|
+
@extracted_images ||= @pages.each_with_index.with_object([]) do |(page, index), result|
|
40
|
+
result << { page: index + 1, images: extract_images(page) }
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def extract_images(page)
|
45
|
+
xobjects = page.xobjects
|
46
|
+
return if xobjects.empty?
|
47
|
+
|
48
|
+
xobjects.each_value.map do |stream|
|
49
|
+
case stream.hash[:Subtype]
|
50
|
+
when :Image
|
51
|
+
process_image_stream(stream)
|
52
|
+
when :Form
|
53
|
+
extract_images(PDF::Reader::FormXObject.new(page, stream))
|
54
|
+
end
|
55
|
+
end.flatten
|
56
|
+
end
|
57
|
+
|
58
|
+
def process_image_stream(stream)
|
59
|
+
filter = Array(stream.hash[:Filter]).first
|
60
|
+
raw = extract_raw_image_data(stream, filter)
|
61
|
+
|
62
|
+
return nil if raw.nil? || raw.empty?
|
63
|
+
|
64
|
+
create_vips_image(raw, filter)
|
65
|
+
end
|
66
|
+
|
67
|
+
def extract_raw_image_data(stream, filter)
|
68
|
+
case filter
|
69
|
+
when :DCTDecode, :JPXDecode then stream.data
|
70
|
+
when :CCITTFaxDecode then tiff_header_for_CCITT(stream.hash, stream.data)
|
71
|
+
when :LZWDecode, :RunLengthDecode, :FlateDecode then handle_compressed_image(stream)
|
72
|
+
else
|
73
|
+
Bidi2pdf.logger.warn("Unsupported image filter '#{filter}'. Attempting to process raw data.")
|
74
|
+
stream.data
|
75
|
+
end
|
76
|
+
rescue StandardError => e
|
77
|
+
Bidi2pdf.logger.error("Error extracting raw image data with filter '#{filter}': #{e.message}")
|
78
|
+
nil # Return nil to indicate failure
|
79
|
+
end
|
80
|
+
|
81
|
+
def handle_compressed_image(stream)
|
82
|
+
hash = stream.hash
|
83
|
+
data = stream.unfiltered_data
|
84
|
+
|
85
|
+
header = tiff_header(hash, data)
|
86
|
+
|
87
|
+
header + data
|
88
|
+
end
|
89
|
+
|
90
|
+
def create_vips_image(raw, filter)
|
91
|
+
Vips::Image.new_from_buffer(raw, "", disc: true)
|
92
|
+
rescue Vips::Error => e
|
93
|
+
Bidi2pdf.logger.error("Error creating Vips image from buffer (filter: #{filter}): #{e.message}")
|
94
|
+
nil # Return nil if Vips fails
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module TestHelpers
|
5
|
+
module Images
|
6
|
+
require "dhash-vips"
|
7
|
+
|
8
|
+
class ImageSimilarityChecker
|
9
|
+
def initialize(expected_image, image_to_check)
|
10
|
+
@expected_image = expected_image.is_a?(Vips::Image) ? expected_image : Vips::Image.new_from_file(expected_image)
|
11
|
+
@image_to_check = image_to_check.is_a?(Vips::Image) ? image_to_check : Vips::Image.new_from_file(image_to_check)
|
12
|
+
end
|
13
|
+
|
14
|
+
def similar?(tolerance: 20)
|
15
|
+
distance < tolerance
|
16
|
+
end
|
17
|
+
|
18
|
+
def very_similar?
|
19
|
+
similar? tolerance: 20
|
20
|
+
end
|
21
|
+
|
22
|
+
def slightly_similar?
|
23
|
+
similar? tolerance: 25
|
24
|
+
end
|
25
|
+
|
26
|
+
def different?
|
27
|
+
!slightly_similar?
|
28
|
+
end
|
29
|
+
|
30
|
+
def expected_fingerprint
|
31
|
+
@expected_fingerprint ||= fingerprint @expected_image
|
32
|
+
end
|
33
|
+
|
34
|
+
def actual_fingerprint
|
35
|
+
@actual_fingerprint ||= fingerprint @image_to_check
|
36
|
+
end
|
37
|
+
|
38
|
+
def distance
|
39
|
+
@distance ||= DHashVips::IDHash.distance(expected_fingerprint, actual_fingerprint)
|
40
|
+
end
|
41
|
+
|
42
|
+
def fingerprint(image)
|
43
|
+
image = image.resize(32.0 / [image.width, image.height].min) if image.width < 32 || image.height < 32
|
44
|
+
|
45
|
+
DHashVips::IDHash.fingerprint image
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module TestHelpers
|
5
|
+
module Images
|
6
|
+
# rubocop: disable Metrics/ModuleLength, Metrics/AbcSize
|
7
|
+
module TIFFHelper
|
8
|
+
# TIFF Tag IDs
|
9
|
+
IMAGE_WIDTH = 256
|
10
|
+
IMAGE_LENGTH = 257
|
11
|
+
BITS_PER_SAMPLE = 258
|
12
|
+
COMPRESSION = 259
|
13
|
+
PHOTOMETRIC_INTERPRETATION = 262
|
14
|
+
STRIP_OFFSETS = 273
|
15
|
+
SAMPLES_PER_PIXEL = 277
|
16
|
+
ROWS_PER_STRIP = 278
|
17
|
+
STRIP_BYTE_COUNTS = 279
|
18
|
+
PLANAR_CONFIGURATION = 284
|
19
|
+
INK_SET = 332
|
20
|
+
|
21
|
+
# TIFF Data Types
|
22
|
+
TYPE_SHORT = 3
|
23
|
+
TYPE_LONG = 4
|
24
|
+
|
25
|
+
# TIFF Compression Types
|
26
|
+
COMPRESSION_NONE = 1
|
27
|
+
COMPRESSION_CCITT_G3 = 3
|
28
|
+
COMPRESSION_CCITT_G4 = 4
|
29
|
+
|
30
|
+
# TIFF Photometric Interpretations
|
31
|
+
PHOTO_WHITE_IS_ZERO = 0
|
32
|
+
PHOTO_BLACK_IS_ZERO = 1
|
33
|
+
PHOTO_RGB = 2
|
34
|
+
PHOTO_SEPARATION = 5
|
35
|
+
|
36
|
+
# Planar Configuration
|
37
|
+
PLANAR_CHUNKY = 1
|
38
|
+
|
39
|
+
def tiff_header(hash, data)
|
40
|
+
cs_entry = hash[:ColorSpace]
|
41
|
+
|
42
|
+
cs = if cs_entry.is_a?(Array) && cs_entry.first == :ICCBased
|
43
|
+
icc_stream = cs_entry[1]
|
44
|
+
icc_stream.hash[:Alternate]
|
45
|
+
else
|
46
|
+
cs_entry
|
47
|
+
end
|
48
|
+
|
49
|
+
case cs
|
50
|
+
when :DeviceCMYK then tiff_header_for_CMYK(hash, data)
|
51
|
+
when :DeviceGray then tiff_header_for_gray(hash, data)
|
52
|
+
when :DeviceRGB then tiff_header_for_rgb(hash, data)
|
53
|
+
else
|
54
|
+
logger.warn("Unsupported color space '#{cs}' for compressed image with filter '#{hash[:Filter]}'. Skipping image.")
|
55
|
+
nil # Skip processing this image
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# See:
|
60
|
+
# * https://gist.github.com/gstorer/f6a9f1dfe41e8e64dcf58d07afa9ab2a
|
61
|
+
# * https://github.com/yob/pdf-reader/blob/main/examples/extract_images.rb
|
62
|
+
def pack_tiff(entries)
|
63
|
+
fields = entries.size
|
64
|
+
fmt = [
|
65
|
+
"a2", # Byte order ("II")
|
66
|
+
"S<", # TIFF magic (42)
|
67
|
+
"L<", # Offset to first IFD (8)
|
68
|
+
"S<", # Number of directory entries
|
69
|
+
("S< S< L< L<" * fields), # each tag: id, type, count, value
|
70
|
+
"L<" # Next IFD offset (0 = end)
|
71
|
+
].join(" ")
|
72
|
+
|
73
|
+
# Build the flat array: ['II', 42, 8, fields, tag1, type1, count1, value1, …, 0]
|
74
|
+
values = ["II", 42, 8, fields] + entries.flatten + [0]
|
75
|
+
values.pack(fmt)
|
76
|
+
end
|
77
|
+
|
78
|
+
def tiff_header_for_gray(hash, data)
|
79
|
+
width = hash[:Width]
|
80
|
+
height = hash[:Height]
|
81
|
+
bpc = hash[:BitsPerComponent] || 8
|
82
|
+
img_size = data.bytesize
|
83
|
+
|
84
|
+
# 9 tags, no extra arrays needed
|
85
|
+
fields = 9
|
86
|
+
# size of header+IFD before the image data starts
|
87
|
+
header_ifd_size = 2 + 2 + 4 + 2 + (fields * 12) + 4
|
88
|
+
data_offset = header_ifd_size
|
89
|
+
|
90
|
+
entries = [
|
91
|
+
[IMAGE_WIDTH, TYPE_LONG, 1, width], # ImageWidth
|
92
|
+
[IMAGE_LENGTH, TYPE_LONG, 1, height], # ImageLength
|
93
|
+
[BITS_PER_SAMPLE, TYPE_SHORT, 1, bpc], # BitsPerSample
|
94
|
+
[COMPRESSION, TYPE_SHORT, 1, COMPRESSION_NONE], # Compression (1 = none)
|
95
|
+
[PHOTOMETRIC_INTERPRETATION, TYPE_SHORT, 1, PHOTO_BLACK_IS_ZERO], # PhotometricInterpretation (1 = BlackIsZero)
|
96
|
+
[STRIP_OFFSETS, TYPE_LONG, 1, data_offset], # StripOffsets
|
97
|
+
[SAMPLES_PER_PIXEL, TYPE_SHORT, 1, 1], # SamplesPerPixel
|
98
|
+
[STRIP_BYTE_COUNTS, TYPE_LONG, 1, img_size], # StripByteCounts
|
99
|
+
[PLANAR_CONFIGURATION, TYPE_SHORT, 1, PLANAR_CHUNKY] # PlanarConfiguration (1 = chunky)
|
100
|
+
]
|
101
|
+
|
102
|
+
pack_tiff(entries)
|
103
|
+
end
|
104
|
+
|
105
|
+
def tiff_header_for_ccitt(hash, data)
|
106
|
+
dp = hash[:DecodeParms] || {}
|
107
|
+
width = dp[:Columns] || hash[:Width]
|
108
|
+
height = hash[:Height]
|
109
|
+
k = dp[:K] || 0
|
110
|
+
group = (k.positive? ? COMPRESSION_CCITT_G3 : COMPRESSION_CCITT_G4)
|
111
|
+
img_size = data.bytesize
|
112
|
+
|
113
|
+
# We’ll emit exactly 8 tags:
|
114
|
+
fields = 8
|
115
|
+
# Calculate where the image data will start:
|
116
|
+
header_size = 2 + 2 + 4 + 2 + (fields * 12) + 4
|
117
|
+
|
118
|
+
entries = [
|
119
|
+
[IMAGE_WIDTH, TYPE_LONG, 1, width], # ImageWidth
|
120
|
+
[IMAGE_LENGTH, TYPE_LONG, 1, height], # ImageLength
|
121
|
+
[BITS_PER_SAMPLE, TYPE_SHORT, 1, 1], # BitsPerSample
|
122
|
+
[COMPRESSION, TYPE_SHORT, 1, group], # Compression (3=G3, 4=G4)
|
123
|
+
[PHOTOMETRIC_INTERPRETATION, TYPE_SHORT, 1, PHOTO_WHITE_IS_ZERO], # PhotometricInterpretation (0 = WhiteIsZero)
|
124
|
+
[STRIP_OFFSETS, TYPE_LONG, 1, header_size], # StripOffsets
|
125
|
+
[ROWS_PER_STRIP, TYPE_LONG, 1, height], # RowsPerStrip
|
126
|
+
[STRIP_BYTE_COUNTS, TYPE_LONG, 1, img_size] # StripByteCounts
|
127
|
+
]
|
128
|
+
|
129
|
+
pack_tiff(entries)
|
130
|
+
end
|
131
|
+
|
132
|
+
def tiff_header_for_cmyk(hash, data)
|
133
|
+
width = hash[:Width]
|
134
|
+
height = hash[:Height]
|
135
|
+
bpc = hash[:BitsPerComponent] || 8
|
136
|
+
img_size = data.bytesize
|
137
|
+
|
138
|
+
# CMYK needs 10 tags + a 4×SHORT BitsPerSample array
|
139
|
+
fields = 10
|
140
|
+
bits_array_size = 4 * 2 # 4 channels × 2 bytes each
|
141
|
+
|
142
|
+
# Size of header + IFD (before the bits array)
|
143
|
+
header_ifd_size = 2 + 2 + 4 + 2 + (fields * 12) + 4
|
144
|
+
# Where the pixel data will really start:
|
145
|
+
data_offset = header_ifd_size + bits_array_size
|
146
|
+
|
147
|
+
entries = [
|
148
|
+
[IMAGE_WIDTH, TYPE_LONG, 1, width], # ImageWidth
|
149
|
+
[IMAGE_LENGTH, TYPE_LONG, 1, height], # ImageLength
|
150
|
+
[BITS_PER_SAMPLE, TYPE_SHORT, 4, header_ifd_size], # BitsPerSample (pointer to array)
|
151
|
+
[COMPRESSION, TYPE_SHORT, 1, COMPRESSION_NONE], # Compression (1 = none)
|
152
|
+
[PHOTOMETRIC_INTERPRETATION, TYPE_SHORT, 1, PHOTO_SEPARATION], # PhotometricInterpretation (5 = Separation)
|
153
|
+
[STRIP_OFFSETS, TYPE_LONG, 1, data_offset], # StripOffsets
|
154
|
+
[SAMPLES_PER_PIXEL, TYPE_SHORT, 1, 4], # SamplesPerPixel
|
155
|
+
[STRIP_BYTE_COUNTS, TYPE_LONG, 1, img_size], # StripByteCounts
|
156
|
+
[PLANAR_CONFIGURATION, TYPE_SHORT, 1, PLANAR_CHUNKY], # PlanarConfiguration (1 = chunky)
|
157
|
+
[INK_SET, TYPE_SHORT, 1, 1] # InkSet (1 = CMYK)
|
158
|
+
]
|
159
|
+
|
160
|
+
header = pack_tiff(entries)
|
161
|
+
# Append the 4-channel BitsPerSample array as little‐endian SHORTs:
|
162
|
+
header << [bpc, bpc, bpc, bpc].pack("S<S<S<S<")
|
163
|
+
header
|
164
|
+
end
|
165
|
+
|
166
|
+
def tiff_header_for_rgb(hash, data)
|
167
|
+
width = hash[:Width]
|
168
|
+
height = hash[:Height]
|
169
|
+
bpc = hash[:BitsPerComponent] || 8
|
170
|
+
img_size = data.bytesize
|
171
|
+
|
172
|
+
# 8 tags + a 3×SHORT BitsPerSample array
|
173
|
+
fields = 8
|
174
|
+
bits_array_size = 3 * 2 # 3 channels × 2 bytes each
|
175
|
+
|
176
|
+
# size of header + IFD before the bits array
|
177
|
+
header_ifd_size = 2 + 2 + 4 + 2 + (fields * 12) + 4
|
178
|
+
# where the pixel data really starts:
|
179
|
+
data_offset = header_ifd_size + bits_array_size
|
180
|
+
|
181
|
+
entries = [
|
182
|
+
[IMAGE_WIDTH, TYPE_LONG, 1, width], # ImageWidth
|
183
|
+
[IMAGE_LENGTH, TYPE_LONG, 1, height], # ImageLength
|
184
|
+
[BITS_PER_SAMPLE, TYPE_SHORT, 3, header_ifd_size], # BitsPerSample → pointer to our array
|
185
|
+
[COMPRESSION, TYPE_SHORT, 1, COMPRESSION_NONE], # Compression (1 = none)
|
186
|
+
[PHOTOMETRIC_INTERPRETATION, TYPE_SHORT, 1, PHOTO_RGB], # PhotometricInterpretation (2 = RGB)
|
187
|
+
[STRIP_OFFSETS, TYPE_LONG, 1, data_offset], # StripOffsets
|
188
|
+
[SAMPLES_PER_PIXEL, TYPE_SHORT, 1, 3], # SamplesPerPixel
|
189
|
+
[STRIP_BYTE_COUNTS, TYPE_LONG, 1, img_size] # StripByteCounts
|
190
|
+
]
|
191
|
+
|
192
|
+
# pack the IFD
|
193
|
+
header = pack_tiff(entries)
|
194
|
+
|
195
|
+
# append the 3-channel BitsPerSample as little-endian SHORTs
|
196
|
+
header << [bpc, bpc, bpc].pack("S<S<S<")
|
197
|
+
|
198
|
+
header
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
# rubocop: enable Metrics/ModuleLength, Metrics/AbcSize
|
203
|
+
end
|
204
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
%w[vips dhash-vips].each do |dep|
|
4
|
+
require dep
|
5
|
+
rescue LoadError
|
6
|
+
warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf image test helpers."
|
7
|
+
end
|
8
|
+
|
9
|
+
require_relative "images/tiff_helper"
|
10
|
+
require_relative "images/extractor"
|
11
|
+
require_relative "images/image_similarity_checker"
|
12
|
+
require_relative "matchers/contains_pdf_image"
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec::Matchers.define :contains_pdf_image do |expected, tolerance: 10|
|
4
|
+
chain :at_page do |page_number|
|
5
|
+
@page_number = page_number
|
6
|
+
end
|
7
|
+
|
8
|
+
chain :at_position do |i|
|
9
|
+
@image_number = i
|
10
|
+
end
|
11
|
+
|
12
|
+
match do |actual_pdf|
|
13
|
+
extractor = Bidi2pdf::TestHelpers::Images::Extractor.new(actual_pdf)
|
14
|
+
@images = if @page_number
|
15
|
+
@image_number ? [extractor.image_on_page(@page_number, @image_number)].compact : extractor.images_on_page(@page_number)
|
16
|
+
else
|
17
|
+
extractor.all_images
|
18
|
+
end
|
19
|
+
|
20
|
+
@checkers = @images.map { |image| Bidi2pdf::TestHelpers::Images::ImageSimilarityChecker.new(expected, image) }
|
21
|
+
|
22
|
+
@checkers.any? { |checker| checker.similar?(tolerance:) }
|
23
|
+
end
|
24
|
+
|
25
|
+
failure_message do |_actual_pdf|
|
26
|
+
"expected to find one image #{"on page #{@page_number}" if @page_number}#{" at position #{@image_number}" if @image_number} to be perceptually similar (distance ≤ #{tolerance}), " \
|
27
|
+
"but Hamming distances have been #{@checkers.map(&:distance).join(", ")}"
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bidi2pdf
|
4
|
+
module TestHelpers
|
5
|
+
# This module provides helper methods for handling PDF files in tests.
|
6
|
+
# It includes methods for debugging, storing, and managing PDF files.
|
7
|
+
module PdfFileHelper
|
8
|
+
# Executes a block with the given PDF data and handles debugging in case of test failures.
|
9
|
+
# If an expectation fails, the PDF data is saved to a file for debugging purposes.
|
10
|
+
# @param [String] pdf_data the PDF data to debug
|
11
|
+
# @yield [String] yields the PDF data to the given block
|
12
|
+
# @raise [RSpec::Expectations::ExpectationNotMetError] re-raises the exception after saving the PDF
|
13
|
+
def with_pdf_debug(pdf_data)
|
14
|
+
yield pdf_data
|
15
|
+
rescue RSpec::Expectations::ExpectationNotMetError => e
|
16
|
+
failure_output = store_pdf_file pdf_data, "test-failure"
|
17
|
+
puts "Test failed! PDF saved to: #{failure_output}"
|
18
|
+
raise e
|
19
|
+
end
|
20
|
+
|
21
|
+
# Stores the given PDF data to a file with a specified filename prefix.
|
22
|
+
# The file is saved in a temporary directory.
|
23
|
+
# @param [String] pdf_data the PDF data to store
|
24
|
+
# @param [String] filename_prefix the prefix for the generated filename (default: "test")
|
25
|
+
# @return [String] the full path to the saved PDF file
|
26
|
+
def store_pdf_file(pdf_data, filename_prefix = "test")
|
27
|
+
pdf_file = tmp_file("pdf-files", "#{filename_prefix}-#{Time.now.to_i}.pdf")
|
28
|
+
FileUtils.mkdir_p(File.dirname(pdf_file))
|
29
|
+
File.binwrite(pdf_file, pdf_data)
|
30
|
+
|
31
|
+
pdf_file
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
RSpec.configure do |config|
|
36
|
+
config.include PdfFileHelper, pdf: true
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module provides helper methods for managing paths in test environments.
|
4
|
+
# It includes methods to retrieve directories and generate temporary file paths.
|
5
|
+
module Bidi2pdf
|
6
|
+
module TestHelpers
|
7
|
+
# This submodule contains path-related helper methods and configuration for tests.
|
8
|
+
module SpecPathsHelper
|
9
|
+
# Retrieves the directory path for Docker files.
|
10
|
+
# @return [String] the Docker directory path
|
11
|
+
def fixture_dir
|
12
|
+
TestHelpers.configuration.fixture_dir
|
13
|
+
end
|
14
|
+
|
15
|
+
# Retrieves the directory path for fixtures.
|
16
|
+
# @return [String] the fixture directory path
|
17
|
+
def fixture_file(*)
|
18
|
+
File.join(fixture_dir, *)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Retrieves the directory path for specs.
|
22
|
+
# @return [String] the spec directory path
|
23
|
+
def spec_dir
|
24
|
+
TestHelpers.configuration.spec_dir
|
25
|
+
end
|
26
|
+
|
27
|
+
# Retrieves the directory path for temporary files.
|
28
|
+
# @return [String] the temporary directory path
|
29
|
+
def tmp_dir
|
30
|
+
TestHelpers.configuration.tmp_dir
|
31
|
+
end
|
32
|
+
|
33
|
+
# Generates a path for a temporary file by joining the temporary directory with the given parts.
|
34
|
+
# @param [Array<String>] parts the parts of the file path to join
|
35
|
+
# @return [String] the full path to the temporary file
|
36
|
+
def tmp_file(*parts)
|
37
|
+
File.join(tmp_dir, *parts)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Generates a random temporary directory path.
|
41
|
+
# @param [Array<String>] dirs additional directory components to include in the path
|
42
|
+
# @param [String, nil] prefix an optional prefix for the directory name
|
43
|
+
# @return [String] the full path to the random temporary directory
|
44
|
+
def random_tmp_dir(*dirs, prefix: nil)
|
45
|
+
base_dirs = [tmp_dir] + dirs.compact
|
46
|
+
pfx = prefix || TestHelpers.configuration.prefix
|
47
|
+
File.join(*base_dirs, "#{pfx}#{SecureRandom.hex(8)}")
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Configures RSpec to include and extend SpecPathsHelper for examples with the `:pdf` metadata.
|
52
|
+
RSpec.configure do |config|
|
53
|
+
# Includes SpecPathsHelper methods in examples with `:pdf` metadata.
|
54
|
+
config.include SpecPathsHelper, pdf: true
|
55
|
+
|
56
|
+
# Extends SpecPathsHelper methods to example groups with `:pdf` metadata.
|
57
|
+
config.extend SpecPathsHelper, pdf: true
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -46,7 +46,7 @@ RSpec.configure do |config|
|
|
46
46
|
config.before(:suite) do
|
47
47
|
if chromedriver_tests_present?
|
48
48
|
config.chromedriver_container = start_chromedriver_container(
|
49
|
-
build_dir: File.join(
|
49
|
+
build_dir: File.join(Bidi2pdf::TestHelpers.configuration.docker_dir, ".."),
|
50
50
|
mounts: config.respond_to?(:chromedriver_mounts) ? config.chromedriver_mounts : {},
|
51
51
|
shared_network: config.shared_network
|
52
52
|
)
|
@@ -6,8 +6,15 @@ rescue LoadError
|
|
6
6
|
warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf test helpers."
|
7
7
|
end
|
8
8
|
|
9
|
+
require "bidi2pdf/test_helpers/configuration"
|
10
|
+
require "bidi2pdf/test_helpers/pdf_file_helper"
|
11
|
+
require "bidi2pdf/test_helpers/spec_paths_helper"
|
9
12
|
require "bidi2pdf/test_helpers/pdf_text_sanitizer"
|
10
13
|
require "bidi2pdf/test_helpers/pdf_reader_utils"
|
11
14
|
require "bidi2pdf/test_helpers/matchers/match_pdf_text"
|
12
15
|
require "bidi2pdf/test_helpers/matchers/contains_pdf_text"
|
13
16
|
require "bidi2pdf/test_helpers/matchers/have_pdf_page_count"
|
17
|
+
|
18
|
+
# don't require "bidi2pdf/test_helpers/matchers/contains_pdf_image.rb" directly, use
|
19
|
+
# require "bidi2pdf/test_helpers/images" instead, because it requires
|
20
|
+
# ruby-vips and dhash-vips, and not every one wants to use them
|
data/lib/bidi2pdf/version.rb
CHANGED