stacked-pdf-generator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 88f906f92904c70c05d8b8c7c17fffaa0c951461f79b23e226006d9a61e382b0
4
+ data.tar.gz: ca874b7ad695d4bbdaf04182b284a27ccea61b3928d3cec634c78e0cbc174972
5
+ SHA512:
6
+ metadata.gz: c2647cb6ec1dab56a3bd668dc20975c313d47f3f9eab44129fb4e4406ddae4d57c1555bd02d2be7b28aec99215a246cb18cad0e530da26e8fb2a3c64632960ff
7
+ data.tar.gz: 95b862c750f88049f5f9213b36803971c2ddbe519199cc5cd833c353e83d72288aa90a9be9337ace3a7d6f5a346c1e37a233ad989014c43c4aa7acba8b137074
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jeremy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # Stacked PDF Generator
2
+
3
+ A Ruby gem and CLI that wraps `pdfjam`, `pdfinfo`, and `podofocrop` to produce
4
+ stack-cut friendly PDFs, relying on the `stacking-order` gem for page sequencing.
5
+
6
+ ## Installation
7
+
8
+ Add to your Gemfile:
9
+
10
+ ```ruby
11
+ gem 'stacked-pdf-generator'
12
+ ```
13
+
14
+ Or install directly:
15
+
16
+ ```bash
17
+ gem install stacked-pdf-generator
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ### Library
23
+
24
+ ```ruby
25
+ require 'stacked_pdf_generator'
26
+
27
+ result = StackedPdfGenerator.call(
28
+ input_path: 'input.pdf',
29
+ output_path: 'output.pdf',
30
+ rows: 7,
31
+ columns: 1,
32
+ paper_size: 'a4',
33
+ autoscale: 'pdfjam',
34
+ portrait: false,
35
+ sheet_margins: '10 10 10 10'
36
+ )
37
+
38
+ if result.success?
39
+ puts 'Generated successfully!'
40
+ else
41
+ warn result.message
42
+ end
43
+ ```
44
+
45
+ ### CLI
46
+
47
+ ```
48
+ stacked-pdf-generator --input input.pdf --output output.pdf --rows 7 --columns 1 \
49
+ --paper-size a4 --autoscale pdfjam --portrait false --sheet-margins "10 10 10 10"
50
+ ```
51
+
52
+ You can continue to pass `--pages-per-sheet N` for backwards compatibility; if
53
+ rows/columns are omitted they fall back to `1 x N`.
54
+
55
+ Run `stacked-pdf-generator --help` for the full list of options.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+
6
+ begin
7
+ require 'stacked_pdf_generator'
8
+ rescue LoadError
9
+ lib_path = File.expand_path('../lib', __dir__)
10
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
11
+ require 'stacked_pdf_generator'
12
+ end
13
+
14
+ options = {}
15
+ parser = OptionParser.new do |opts|
16
+ opts.banner = 'Usage: stacked-pdf-generator --input INPUT --output OUTPUT [options]'
17
+
18
+ opts.on('--input PATH', 'Input PDF path') { |value| options[:input_path] = value }
19
+ opts.on('--output PATH', 'Output PDF path') { |value| options[:output_path] = value }
20
+ opts.on('--pages-per-sheet N', Integer, 'Total pages per sheet (legacy single-dimension input)') do |value|
21
+ options[:pages_per_sheet] = value
22
+ end
23
+ opts.on('--rows N', Integer, 'Number of rows per sheet') { |value| options[:rows] = value }
24
+ opts.on('--columns N', Integer, 'Number of columns per sheet') { |value| options[:columns] = value }
25
+ opts.on('--paper-size SIZE', 'Paper size (a4 or a3)') { |value| options[:paper_size] = value }
26
+ opts.on('--autoscale MODE', 'Autoscale mode (pdfjam, none, podofo)') { |value| options[:autoscale] = value }
27
+ opts.on('--portrait BOOLEAN', 'Portrait layout (true/false)') { |value| options[:portrait] = value }
28
+ opts.on('--sheet-margins "L R T B"', 'Optional sheet margins in mm') { |value| options[:sheet_margins] = value }
29
+
30
+ opts.on('-v', '--version', 'Print version') do
31
+ puts StackedPdfGenerator::VERSION
32
+ exit 0
33
+ end
34
+
35
+ opts.on('-h', '--help', 'Show help') do
36
+ puts opts
37
+ exit 0
38
+ end
39
+ end
40
+
41
+ begin
42
+ parser.parse!(ARGV)
43
+ rescue OptionParser::ParseError => e
44
+ warn e.message
45
+ warn parser
46
+ exit 1
47
+ end
48
+
49
+ required = %i[input_path output_path paper_size autoscale portrait]
50
+ missing = required.reject { |key| options[key] }
51
+ if missing.any?
52
+ warn "Missing required options: #{missing.join(', ')}"
53
+ warn parser
54
+ exit 1
55
+ end
56
+
57
+ if options[:pages_per_sheet].nil? && (options[:rows].nil? || options[:columns].nil?)
58
+ warn 'Specify either --pages-per-sheet or both --rows and --columns'
59
+ warn parser
60
+ exit 1
61
+ end
62
+
63
+ result = StackedPdfGenerator.call(
64
+ input_path: options[:input_path],
65
+ output_path: options[:output_path],
66
+ pages_per_sheet: options[:pages_per_sheet],
67
+ rows: options[:rows],
68
+ columns: options[:columns],
69
+ paper_size: options[:paper_size],
70
+ autoscale: options[:autoscale],
71
+ portrait: options[:portrait],
72
+ sheet_margins: options[:sheet_margins]
73
+ )
74
+
75
+ if result.success?
76
+ puts 'PDF generated successfully.'
77
+ else
78
+ warn result.message
79
+ exit 1
80
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StackedPdfGenerator
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+ require 'stacking_order'
7
+
8
+ require_relative 'stacked_pdf_generator/version'
9
+
10
+ # Provides library and CLI helpers for generating stack-cut friendly PDFs using
11
+ # pdfjam/podofocrop tooling and stacking-order-based page sequencing.
12
+ module StackedPdfGenerator
13
+ ProcessingError = Class.new(StandardError)
14
+
15
+ Result = Struct.new(:success?, :message, keyword_init: true)
16
+
17
+ module_function
18
+
19
+ def call(**kwargs)
20
+ Generator.new(**kwargs).call
21
+ end
22
+
23
+ # Performs the heavy lifting: validates inputs, shells out to pdfjam/podofocrop,
24
+ # and sequences pages via stacking-order to build the final PDF.
25
+ class Generator
26
+ attr_reader :input_path, :output_path, :paper_size, :autoscale, :portrait,
27
+ :sheet_margins_raw, :rows, :columns, :pages_per_sheet
28
+
29
+ def initialize(input_path:, output_path:, paper_size:, autoscale:, portrait:, rows: nil, columns: nil,
30
+ pages_per_sheet: nil, sheet_margins: nil)
31
+ @input_path = input_path
32
+ @output_path = output_path
33
+ @paper_size = paper_size.to_s.upcase
34
+ @autoscale = autoscale.to_s
35
+ @portrait = boolean_cast(portrait)
36
+ @sheet_margins_raw = sheet_margins
37
+ @rows = rows.nil? ? nil : Integer(rows)
38
+ @columns = columns.nil? ? nil : Integer(columns)
39
+ @pages_per_sheet = pages_per_sheet.nil? ? nil : Integer(pages_per_sheet)
40
+ normalize_layout_dimensions!
41
+ end
42
+
43
+ def call
44
+ validate_arguments!
45
+ run_pdfjam
46
+ finalize_output
47
+ Result.new(success?: true, message: '')
48
+ rescue ProcessingError => e
49
+ Result.new(success?: false, message: e.message)
50
+ ensure
51
+ cleanup_tempfile
52
+ end
53
+
54
+ private
55
+
56
+ def validate_arguments!
57
+ raise ProcessingError, 'Missing input PDF' unless present?(input_path) && File.exist?(input_path)
58
+ raise ProcessingError, 'Missing output path' if blank?(output_path)
59
+ raise ProcessingError, 'pages_per_sheet must be positive' unless pages_per_sheet.positive?
60
+ end
61
+
62
+ def run_pdfjam
63
+ sequence = page_sequence
64
+ cmd = [
65
+ 'pdfjam', input_path, sequence,
66
+ '-o', temp_output_path,
67
+ '--nup', "#{columns}x#{rows}",
68
+ '--paper', paper_size_option
69
+ ]
70
+
71
+ cmd.concat(%w[--noautoscale true]) if %w[none podofo].include?(autoscale)
72
+ cmd << '--landscape' unless portrait
73
+ cmd << '--quiet'
74
+
75
+ if sheet_margins_mm
76
+ margin_string = sheet_margins_mm.map { |value| format('%gmm', value) }.join(' ')
77
+ cmd.concat(['--trim', margin_string, '--clip', 'true'])
78
+ end
79
+
80
+ stdout, stderr, status = Open3.capture3(*cmd)
81
+ raise ProcessingError, format_failure('pdfjam', stdout, stderr) unless status.success?
82
+ end
83
+
84
+ def finalize_output
85
+ if autoscale == 'podofo'
86
+ stdout, stderr, status = Open3.capture3('podofocrop', temp_output_path, output_path)
87
+ raise ProcessingError, format_failure('podofocrop', stdout, stderr) unless status.success?
88
+
89
+ FileUtils.rm_f(temp_output_path)
90
+ else
91
+ FileUtils.mv(temp_output_path, output_path)
92
+ end
93
+ end
94
+
95
+ def cleanup_tempfile
96
+ FileUtils.rm_f(temp_output_path) if defined?(@temp_output_path) && File.exist?(@temp_output_path)
97
+ end
98
+
99
+ def temp_output_path
100
+ @temp_output_path ||= begin
101
+ dirname = File.dirname(output_path)
102
+ FileUtils.mkdir_p(dirname)
103
+ File.join(dirname, "stacked_tmp_#{SecureRandom.hex(6)}.pdf")
104
+ end
105
+ end
106
+
107
+ def format_failure(tool, stdout, stderr)
108
+ details = presence(stderr) || presence(stdout) || 'Unknown error'
109
+ "#{tool} failed: #{details.strip}"
110
+ end
111
+
112
+ def sheet_margins_mm
113
+ return @sheet_margins_mm if defined?(@sheet_margins_mm)
114
+ return (@sheet_margins_mm = nil) if blank?(sheet_margins_raw)
115
+
116
+ values = sheet_margins_raw.split.map do |value|
117
+ Float(value)
118
+ rescue ArgumentError
119
+ nil
120
+ end
121
+
122
+ @sheet_margins_mm = values.compact.length == 4 ? values.first(4) : nil
123
+ end
124
+
125
+ def paper_size_option
126
+ paper_size == 'A3' ? 'a3paper' : 'a4paper'
127
+ end
128
+
129
+ def page_sequence
130
+ total_pages = detect_page_count
131
+ order = StackingOrder.order(entries: total_pages, rows: rows, columns: columns)
132
+ cells_per_page = rows * columns
133
+
134
+ remainder = order.length % cells_per_page
135
+ order += [nil] * (cells_per_page - remainder) unless remainder.zero?
136
+
137
+ order.map { |value| value || '{}' }.join(',')
138
+ end
139
+
140
+ def detect_page_count
141
+ stdout, stderr, status = Open3.capture3('pdfinfo', input_path)
142
+ raise ProcessingError, format_failure('pdfinfo', stdout, stderr) unless status.success?
143
+
144
+ match = stdout.match(/Pages:\s+(\d+)/)
145
+ raise ProcessingError, 'Unable to determine page count' unless match
146
+
147
+ match[1].to_i
148
+ end
149
+
150
+ def normalize_layout_dimensions!
151
+ if rows && columns
152
+ ensure_positive_dimensions!
153
+ @pages_per_sheet ||= rows * columns
154
+ elsif pages_per_sheet
155
+ @rows ||= pages_per_sheet
156
+ @columns ||= 1
157
+ ensure_positive_dimensions!
158
+ @pages_per_sheet = rows * columns
159
+ else
160
+ raise ProcessingError, 'Provide either pages_per_sheet or both rows and columns'
161
+ end
162
+ end
163
+
164
+ def ensure_positive_dimensions!
165
+ raise ProcessingError, 'rows must be positive' unless rows.positive?
166
+ raise ProcessingError, 'columns must be positive' unless columns.positive?
167
+ end
168
+ def boolean_cast(value)
169
+ return value if value == true || value == false
170
+ return true if value.is_a?(Numeric) && value != 0
171
+ return false if value.is_a?(Numeric)
172
+
173
+ if value.is_a?(String)
174
+ stripped = value.strip.downcase
175
+ return true if %w[true t 1 yes y].include?(stripped)
176
+ return false if %w[false f 0 no n].include?(stripped)
177
+ end
178
+
179
+ !!value
180
+ end
181
+
182
+ def blank?(value)
183
+ return true if value.nil?
184
+ return true if value.is_a?(String) && value.strip.empty?
185
+ return value.empty? if value.respond_to?(:empty?)
186
+
187
+ false
188
+ end
189
+
190
+ def present?(value)
191
+ !blank?(value)
192
+ end
193
+
194
+ def presence(value)
195
+ present?(value) ? value : nil
196
+ end
197
+ end
198
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stacked-pdf-generator
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-11-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: stacking-order
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.0.0
27
+ description: Wraps pdfjam/podofocrop/pdfinfo to automate stacked layouts, relying
28
+ on stacking-order for sequencing.
29
+ email:
30
+ - jeremy@example.com
31
+ executables:
32
+ - stacked-pdf-generator
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE.txt
37
+ - README.md
38
+ - exe/stacked-pdf-generator
39
+ - lib/stacked_pdf_generator.rb
40
+ - lib/stacked_pdf_generator/version.rb
41
+ homepage: https://github.com/jeremy/stacked-pdf-generator
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ homepage_uri: https://github.com/jeremy/stacked-pdf-generator
46
+ source_code_uri: https://github.com/jeremy/stacked-pdf-generator
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.1'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.5.16
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Generate stack-cut friendly PDFs using pdfjam.
66
+ test_files: []