Skip to content

Commit e7f79c6

Browse files
authoredSep 5, 2022
resolves #2330 allow PDF optimizer to be pluggable (PR #2332)
1 parent aae57ce commit e7f79c6

File tree

8 files changed

+221
-101
lines changed

8 files changed

+221
-101
lines changed
 

‎CHANGELOG.adoc

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Enhancements::
1919
* rename `kbd-separator` theme key to `kbd-separator-content` to make its consistent with other content keys; remap old key and warn
2020
* preserve em and rem units on numerator in calc operation in theme (#2314)
2121
* allow optimizer to be specified using `:pdf_optimizer` API option (#1785)
22+
* allow custom optimizer to be registered by extending `Asciidoctor::PDF::Optimizer::Base` and calling `register_for` method (#2330)
23+
* allow custom optimizer to be selected using `pdf-optimizer` attribute (#2330)
2224

2325
Improvements::
2426

‎bin/asciidoctor-pdf-optimize

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
#!/usr/bin/env ruby
22
# frozen_string_literal: true
33

4-
if File.file? (optimizer = File.join (File.dirname __dir__), 'lib/asciidoctor/pdf/optimizer.rb')
4+
if File.file? (optimizer = File.join (File.dirname __dir__), 'lib/asciidoctor/pdf/optimizer/rghost.rb')
55
require optimizer
66
else
7-
require 'asciidoctor/pdf/optimizer'
7+
require 'asciidoctor/pdf/optimizer/rghost'
88
end
99

1010
args = ARGV.dup
@@ -16,4 +16,4 @@ end
1616

1717
quality = args[0] == '--quality' ? args[1].to_s : ''
1818

19-
(Asciidoctor::PDF::Optimizer.new quality).optimize_file filename
19+
(Asciidoctor::PDF::Optimizer::RGhost.new quality).optimize_file filename
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
require 'hexapdf/cli'
2+
3+
class OptimizerHexaPDF < Asciidoctor::PDF::Optimizer::Base
4+
register_for 'hexapdf'
5+
6+
def initialize *_args
7+
super
8+
app = HexaPDF::CLI::Application.new
9+
app.instance_variable_set :@force, true
10+
@optimize = app.main_command.commands['optimize']
11+
@optimize.singleton_class.attr_reader :out_options
12+
options = @optimize.out_options
13+
options.compress_pages = true
14+
#options.object_streams = :preserve
15+
#options.xref_streams = :preserve
16+
#options.streams = :preserve # or :uncompress
17+
end
18+
19+
def optimize_file path
20+
@optimize.execute path, path
21+
nil
22+
rescue
23+
# retry without page compression, which can sometimes fail
24+
@optimize.out_options.compress_pages = false
25+
@optimize.execute path, path
26+
nil
27+
ensure
28+
@optimize.out_options.compress_pages = false
29+
end
30+
end

‎docs/modules/ROOT/pages/optimize-pdf.adoc

+29-34
Original file line numberDiff line numberDiff line change
@@ -135,52 +135,47 @@ This command does not manipulate the images in any way.
135135
It merely compresses the objects in the PDF and prunes any unreachable references.
136136
But given how much waste Prawn leaves behind, this turns out to reduce the file size substantially.
137137

138-
You can hook this command directly into the converter by providing your own implementation of the `Optimizer` class.
139-
Start by creating a Ruby file named [.path]_optimizer-hexapdf.rb_, then populate it with the following code:
138+
To see all the options that `hexapdf optimize` offers, run:
139+
140+
$ hexapdf help optimize
141+
142+
For example, to make the source of the PDF a bit more readable (though less optimized), set the stream-related options to `preserve` (e.g., `--streams preserve` from the CLI or `options.streams = :preserve` from the API).
143+
You can also disable page compression (e.g., `--no-compress-pages` from the CLI or `options.compress_pages = false` from the API).
144+
145+
hexapdf also allows you to add password protection to your PDF, if that's something you're interested in doing.
146+
147+
=== Define an optimizer
148+
149+
You can hook HexaPDF directly into the conversion process by providing your own implementation of the `Optimizer` class.
150+
Start by creating a Ruby file named [.path]_optimizer-hexapdf.rb_ where you will define the optimizer.
151+
Next, populate that file with the following code:
140152

141153
.optimizer-hexapdf.rb
142154
[source,ruby]
143155
----
144-
require 'hexapdf/cli'
145-
146-
class Asciidoctor::PDF::Optimizer
147-
def initialize(*)
148-
app = HexaPDF::CLI::Application.new
149-
app.instance_variable_set :@force, true
150-
@optimize = app.main_command.commands['optimize']
151-
end
152-
153-
def optimize_file path
154-
options = @optimize.instance_variable_get :@out_options
155-
options.compress_pages = true
156-
#options.object_streams = :preserve
157-
#options.xref_streams = :preserve
158-
#options.streams = :preserve # or :uncompress
159-
@optimize.execute path, path
160-
nil
161-
rescue
162-
# retry without page compression, which can sometimes fail
163-
options.compress_pages = false
164-
@optimize.execute path, path
165-
nil
166-
end
167-
end
156+
include::example$optimizer-hexapdf.rb[]
168157
----
169158

170-
To activate your custom optimizer, load this file when invoking the `asciidoctor-pdf` using the `-r` flag and set the `optimize` attribute as well using the `-a` flag.
159+
To activate your custom optimizer when using the `asciidoctor-pdf` command, load the optimizer code using the `-r` flag, then set both the `optimize` and `pdf-optimizer` attributes using the `-a` flag.
171160

172-
$ asciidoctor-pdf -r ./optimizer-hexapdf.rb -a optimize filename.adoc
161+
$ asciidoctor-pdf -r ./optimizer-hexapdf.rb -a optimize -a pdf-optimizer=hexapdf filename.adoc
173162

174-
Now you can convert and optimize all in one go.
163+
If you're calling Asciidoctor PDF using the API, you can pass in the optimizer class directly with the `:pdf_optimizer` option:
175164

176-
To see more options that `hexapdf optimize` offers, run:
165+
[,ruby]
166+
----
167+
require 'asciidoctor/pdf'
168+
require_relative 'optimizer-hexapdf'
177169
178-
$ hexapdf help optimize
170+
Asciidoctor.convert_file 'filename.adoc',
171+
safe: :safe,
172+
attributes: 'optimize',
173+
pdf_optimizer: OptimizerHexaPDF
174+
----
179175

180-
For example, to make the source of the PDF a bit more readable (though less optimized), set the stream-related options to `preserve` (e.g., `--streams preserve` from the CLI or `options.streams = :preserve` from the API).
181-
You can also disable page compression (e.g., `--no-compress-pages` from the CLI or `options.compress_pages = false` from the API).
176+
TIP: When you pass the optimizer class directly to the API, the `register_for` call in the class declaration to self-register the class with a keyword is not required.
182177

183-
hexapdf also allows you to add password protection to your PDF, if that's something you're interested in doing.
178+
You've now converted the input file to PDF and optimized it all in one go!
184179

185180
== Rasterizing the PDF
186181

‎lib/asciidoctor/pdf/converter.rb

+2-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require_relative 'formatted_string'
44
require_relative 'formatted_text'
55
require_relative 'index_catalog'
6+
require_relative 'optimizer'
67
require_relative 'pdfmark'
78
require_relative 'roman_numeral'
89
require_relative 'section_info_by_page'
@@ -37,7 +38,6 @@ class Converter < ::Prawn::Document
3738
CodeRayRequirePath = ::File.join __dir__, 'ext/prawn/coderay_encoder'
3839
RougeRequirePath = ::File.join __dir__, 'ext/rouge'
3940
PygmentsRequirePath = ::File.join __dir__, 'ext/pygments'
40-
OptimizerRequirePath = ::File.join __dir__, 'optimizer'
4141

4242
AdmonitionIcons = {
4343
caution: { name: 'fas-fire', stroke_color: 'BF3400' },
@@ -420,8 +420,7 @@ def init_pdf doc
420420
@pdfmark = (doc.attr? 'pdfmark') ? (Pdfmark.new doc) : nil
421421
# NOTE: defer instantiating optimizer until we know min pdf version
422422
if (optimize = doc.attr 'optimize') &&
423-
(optimizer = doc.options[:pdf_optimizer] || (((defined? ::Asciidoctor::PDF::Optimizer) ||
424-
!(Helpers.require_library OptimizerRequirePath, 'rghost', :warn).nil?) && ::Asciidoctor::PDF::Optimizer))
423+
(optimizer = doc.options[:pdf_optimizer] || (Optimizer.for (doc.attr 'pdf-optimizer', 'rghost')))
425424
@optimize = (optimize.include? ',') ?
426425
([:quality, :compliance].zip (optimize.split ',', 2)).to_h :
427426
((optimize.include? '/') ? { compliance: optimize } : { quality: optimize })

‎lib/asciidoctor/pdf/optimizer.rb

+36-58
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,56 @@
11
# frozen_string_literal: true
22

3-
require 'pathname'
4-
require 'rghost'
5-
require 'rghost/gs_alone'
6-
require 'tmpdir'
7-
8-
RGhost::GSAlone.prepend (Module.new do
9-
def initialize params, debug
10-
(@params = params.dup).push(*(@params.pop.split File::PATH_SEPARATOR))
11-
@debug = debug
12-
end
13-
14-
def run
15-
RGhost::Config.config_platform unless File.exist? RGhost::Config::GS[:path].to_s
16-
(cmd = @params.slice 1, @params.length).unshift RGhost::Config::GS[:path].to_s
17-
#puts cmd if @debug
18-
system(*cmd)
19-
end
20-
end)
21-
22-
RGhost::Engine.prepend (Module.new do
23-
def shellescape str
24-
str
25-
end
26-
end)
27-
283
module Asciidoctor
294
module PDF
30-
class Optimizer
31-
# see https://www.ghostscript.com/doc/current/VectorDevices.htm#PSPDF_IN for details
32-
(QUALITY_NAMES = {
33-
'default' => :default,
34-
'screen' => :screen,
35-
'ebook' => :ebook,
36-
'printer' => :printer,
37-
'prepress' => :prepress,
38-
}).default = :default
39-
5+
module Optimizer
406
attr_reader :quality
417
attr_reader :compatibility_level
428
attr_reader :compliance
439

4410
def initialize quality = 'default', compatibility_level = '1.4', compliance = 'PDF'
45-
@quality = QUALITY_NAMES[quality]
11+
@quality = quality
4612
@compatibility_level = compatibility_level
4713
@compliance = compliance
48-
if (gs_path = ::ENV['GS'])
49-
::RGhost::Config::GS[:path] = gs_path
50-
end
5114
end
5215

5316
def optimize_file target
54-
::Dir::Tmpname.create ['asciidoctor-pdf-', '.pdf'] do |tmpfile|
55-
filename_o = ::Pathname.new target
56-
filename_tmp = ::Pathname.new tmpfile
57-
if (pdfmark = filename_o.sub_ext '.pdfmark').file?
58-
inputs = [target, pdfmark.to_s].join ::File::PATH_SEPARATOR
59-
else
60-
inputs = target
61-
end
62-
d = { Printed: false, CannotEmbedFontPolicy: '/Warning', CompatibilityLevel: @compatibility_level }
63-
case @compliance
64-
when 'PDF/A', 'PDF/A-1', 'PDF/A-2', 'PDF/A-3'
65-
d[:PDFA] = ((@compliance.split '-', 2)[1] || 1).to_i
66-
d[:ShowAnnots] = false
67-
when 'PDF/X', 'PDF/X-1', 'PDF/X-3'
68-
d[:PDFX] = true
69-
d[:ShowAnnots] = false
17+
raise ::NotImplementedError, %(#{Optimizer} subclass #{self.class} must implement the ##{__method__} method)
18+
end
19+
20+
private_class_method def self.included into
21+
into.extend Config
22+
end
23+
24+
module Config
25+
def register_for name
26+
Optimizer.register self, name.to_s
27+
end
28+
end
29+
30+
module Factory
31+
@@registry = {}
32+
33+
def for name
34+
if (optimizer = @@registry[name]).nil? && name == 'rghost'
35+
if (::Asciidoctor::Helpers.require_library %(#{__dir__}/optimizer/rghost), 'rghost', :warn).nil?
36+
@@registry[name] = false
37+
else
38+
optimizer = @@registry[name] = Optimizer::RGhost
39+
end
7040
end
71-
(::RGhost::Convert.new inputs).to :pdf, filename: filename_tmp.to_s, quality: @quality, d: d
72-
filename_o.binwrite filename_tmp.binread
41+
optimizer || nil
42+
end
43+
44+
def register optimizer, name
45+
optimizer ? (@@registry[name] = optimizer) : (@@registry.delete name)
7346
end
74-
nil
7547
end
48+
49+
class Base
50+
include Optimizer
51+
end
52+
53+
extend Factory
7654
end
7755
end
7856
end
+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../optimizer' unless defined? ::Asciidoctor::PDF::Optimizer
4+
require 'pathname'
5+
require 'rghost'
6+
require 'rghost/gs_alone'
7+
require 'tmpdir'
8+
9+
RGhost::GSAlone.prepend (Module.new do
10+
def initialize params, debug
11+
(@params = params.dup).push(*(@params.pop.split File::PATH_SEPARATOR))
12+
@debug = debug
13+
end
14+
15+
def run
16+
RGhost::Config.config_platform unless File.exist? RGhost::Config::GS[:path].to_s
17+
(cmd = @params.slice 1, @params.length).unshift RGhost::Config::GS[:path].to_s
18+
#puts cmd if @debug
19+
system(*cmd)
20+
end
21+
end)
22+
23+
RGhost::Engine.prepend (Module.new do
24+
def shellescape str
25+
str
26+
end
27+
end)
28+
29+
module Asciidoctor
30+
module PDF
31+
class Optimizer::RGhost < Optimizer::Base
32+
# see https://www.ghostscript.com/doc/current/VectorDevices.htm#PSPDF_IN for details
33+
(QUALITY_NAMES = {
34+
'default' => :default,
35+
'screen' => :screen,
36+
'ebook' => :ebook,
37+
'printer' => :printer,
38+
'prepress' => :prepress,
39+
}).default = :default
40+
41+
def initialize *_args
42+
super
43+
if (gs_path = ::ENV['GS'])
44+
::RGhost::Config::GS[:path] = gs_path
45+
end
46+
end
47+
48+
def optimize_file target
49+
::Dir::Tmpname.create ['asciidoctor-pdf-', '.pdf'] do |tmpfile|
50+
filename_o = ::Pathname.new target
51+
filename_tmp = ::Pathname.new tmpfile
52+
if (pdfmark = filename_o.sub_ext '.pdfmark').file?
53+
inputs = [target, pdfmark.to_s].join ::File::PATH_SEPARATOR
54+
else
55+
inputs = target
56+
end
57+
d = { Printed: false, CannotEmbedFontPolicy: '/Warning', CompatibilityLevel: @compatibility_level }
58+
case @compliance
59+
when 'PDF/A', 'PDF/A-1', 'PDF/A-2', 'PDF/A-3'
60+
d[:PDFA] = ((@compliance.split '-', 2)[1] || 1).to_i
61+
d[:ShowAnnots] = false
62+
when 'PDF/X', 'PDF/X-1', 'PDF/X-3'
63+
d[:PDFX] = true
64+
d[:ShowAnnots] = false
65+
end
66+
(::RGhost::Convert.new inputs).to :pdf, filename: filename_tmp.to_s, quality: QUALITY_NAMES[@quality], d: d
67+
filename_o.binwrite filename_tmp.binread
68+
end
69+
nil
70+
end
71+
end
72+
end
73+
end

‎spec/optimizer_spec.rb

+46-3
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
(expect pdf_info[:Title]).to eql 'Document Title'
1919
(expect pdf_info[:Author]).to eql 'Doc Writer'
2020
(expect pdf_info[:Subject]).to eql 'Example'
21-
(expect defined? Asciidoctor::PDF::Optimizer).to be_truthy
2221
# NOTE: assert constructor behavior once we know the class has been loaded
23-
optimizer = Asciidoctor::PDF::Optimizer.new
24-
(expect optimizer.quality).to eql :default
22+
optimizer_class = Asciidoctor::PDF::Optimizer.for 'rghost'
23+
(expect optimizer_class).not_to be_nil
24+
optimizer = optimizer_class.new
25+
(expect optimizer.quality).to eql 'default'
2526
(expect optimizer.compatibility_level).to eql '1.4'
2627
(expect optimizer.compliance).to eql 'PDF'
2728
end
@@ -185,4 +186,46 @@ def self.optimized
185186
(expect optimized[0][:quality]).to eql 'ebook'
186187
(expect optimized[0][:path]).to eql to_file
187188
end
189+
190+
it 'should allow custom PDF optimizer to be registered and used' do
191+
create_class Asciidoctor::PDF::Optimizer::Base do
192+
register_for 'custom'
193+
194+
def optimize_file path
195+
self.class.optimized << { quality: @quality, path: path }
196+
nil
197+
end
198+
199+
def self.optimized
200+
@optimized ||= []
201+
end
202+
end
203+
204+
optimizer = Asciidoctor::PDF::Optimizer.for 'custom'
205+
(expect optimizer).not_to be_nil
206+
input_file = example_file 'basic-example.adoc'
207+
to_file = output_file 'optimizer-custom-registered.pdf'
208+
Asciidoctor.convert_file input_file, backend: 'pdf', attributes: 'optimize=ebook pdf-optimizer=custom', to_file: to_file, safe: :safe
209+
optimized = optimizer.optimized
210+
(expect optimized).to have_size 1
211+
(expect optimized[0][:quality]).to eql 'ebook'
212+
(expect optimized[0][:path]).to eql to_file
213+
ensure
214+
Asciidoctor::PDF::Optimizer.register 'custom', nil
215+
end
216+
217+
it 'should raise error if registered optimizer does not implement optimize_file method' do
218+
create_class Asciidoctor::PDF::Optimizer::Base do
219+
register_for 'custom'
220+
end
221+
222+
(expect Asciidoctor::PDF::Optimizer.for 'custom').not_to be_nil
223+
input_file = example_file 'basic-example.adoc'
224+
to_file = output_file 'optimizer-custom-registered-invalid.pdf'
225+
(expect do
226+
Asciidoctor.convert_file input_file, backend: 'pdf', attributes: 'optimize=ebook pdf-optimizer=custom', to_file: to_file, safe: :safe
227+
end).to raise_exception NotImplementedError, %r/must implement the #optimize_file method/
228+
ensure
229+
Asciidoctor::PDF::Optimizer.register 'custom', nil
230+
end
188231
end)

0 commit comments

Comments
 (0)
Please sign in to comment.