docpdf 0.1.4 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52751516c7b9baebff20d8f3f9bfae0f9b50753497bedd37a0b690da45b5e22d
4
- data.tar.gz: 211a5c562468626013b8427963c28cd4d2b410b24b688fe7ad5dd994566b8aba
3
+ metadata.gz: b68eda5d7355d1d3a227ef71f44d41f95dc7ccbfe7b08af17739e629c9ab26dc
4
+ data.tar.gz: 23e0ab911eb89df49eeac3634b50ef6d8bf8f7b74a8861067e2a0e44f3946e82
5
5
  SHA512:
6
- metadata.gz: dd27677faa12f3ed9e21e66282e8efac2c8e2abbf232a9dc4fd5cc6927da1a7eb0c85ba992ac2672035f7eb01a9c4232fd060ff1113c3a2ebc66b0722c19ac0f
7
- data.tar.gz: 969269269d255c41d6364f6650154a3dfa6c4ba9919e4a5492b9c35e8c74d3277c3aa1d3c0dfd4a8cf4e02cd19cc7b8f895f16d2ff90121b6d342830a113456d
6
+ metadata.gz: 14aff5944d6ac087f241b078c557e480974f322ca2cbcf3c9d08136e6b37f6598ca1bbd17e14267b060632e83e54491dbb5dc6a42b15768eb64883bbe3fd7838
7
+ data.tar.gz: cb43492ce6e3b7257e3c6cc01281904c509dcb473f77e932361ae986f0f39cda9af3bc0a4080b2e8c7d24e2c13adf6d4a8b93230ed455eef6b6ed74ede98e301
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.6] - 2026-09-18
4
+
5
+ ### Added
6
+ - `font_file` registers a TrueType font for text conversion (`config.text_options`) and text watermarks (`config.watermark_options`, or per stamp). PDF's built-in fonts are limited to Windows-1252, so Cyrillic, Greek and other non-Latin text previously raised `ConversionError` with no way around it. Takes a path for the regular weight or a hash of `normal`/`bold`/`italic`/`bold_italic`, and is supported by both text converters and both stampers.
7
+
8
+ ## [0.1.5] - 2026-09-18
9
+
10
+ ### Added
11
+ - `config.converter` selects a preferred converter adapter, mirroring `config.stamper`. It only claims the mime types that adapter registered for, so everything else still resolves in registration order and `config.converter = :hexapdf` does not disturb Word or image conversion.
12
+
13
+ ### Fixed
14
+ - Watermarking a structurally valid PDF with an empty page tree raised `NoMethodError` instead of a `DocPDF::ConversionError`. Both stampers and the per-page watermark path now raise `ConversionError` with a clear message.
15
+ - Plain text containing U+2028 LINE SEPARATOR or U+2029 PARAGRAPH SEPARATOR crashed the Prawn converter, since Windows-1252 has no room for them. Both are now translated to a newline, which is what they mean, so text pasted out of word processors and web pages converts cleanly.
16
+ - Adapters no longer leak third-party exceptions to callers rescuing `DocPDF::Error`. The Prawn converter wraps every `Prawn::Errors` class plus `Encoding::UndefinedConversionError`, and both stampers wrap a missing watermark image file.
17
+
3
18
  ## [0.1.4] - 2026-04-08
4
19
 
5
20
  ### Fixed
data/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  Convert documents from any common format to PDF, with optional watermarking. Zero hard dependencies; bring your own PDF library.
10
10
 
11
11
  [![CI](https://github.com/velocity-labs/docpdf/actions/workflows/ci.yml/badge.svg)](https://github.com/velocity-labs/docpdf/actions/workflows/ci.yml)
12
- [![Gem Version](https://badge.fury.io/rb/docpdf.svg)](https://rubygems.org/gems/docpdf)
12
+ [![Gem Version](https://img.shields.io/gem/v/docpdf?label=gem%20version&color=D3AF37&cacheSeconds=3600)](https://rubygems.org/gems/docpdf)
13
13
 
14
14
  <br clear="left">
15
15
 
@@ -197,12 +197,18 @@ DocPDF.configure do |config|
197
197
  # Stamper adapter for watermarking (default: nil, auto-detects hexapdf then combine_pdf)
198
198
  config.stamper = :hexapdf # or :combine_pdf
199
199
 
200
+ # Preferred converter adapter (default: nil, uses registration order)
201
+ # Only applies to the MIME types that adapter registered for; everything else
202
+ # still resolves normally, so this will not affect Word or image conversion.
203
+ config.converter = :hexapdf # or :prawn, :rmagick, :mini_magick, ...
204
+
200
205
  # Page size (default: "LETTER")
201
206
  config.page_size = "A4"
202
207
 
203
208
  # Plain text file conversion defaults
204
209
  config.text_options = {
205
210
  font: "Helvetica", # default: "Courier"
211
+ font_file: nil, # default: nil (see "Non-Latin text" below)
206
212
  font_size: 12, # default: 10
207
213
  margins: [72, 72, 72, 72], # default: [50, 50, 50, 50] (points: top, right, bottom, left)
208
214
  color: "000000", # default: "333333" (hex)
@@ -211,6 +217,7 @@ DocPDF.configure do |config|
211
217
  # Text watermark defaults (per-stamp options override these)
212
218
  config.watermark_options = {
213
219
  font: "Times", # default: "Helvetica"
220
+ font_file: nil, # default: nil (see "Non-Latin text" below)
214
221
  font_size: 96, # default: 72
215
222
  color: "FF0000", # default: "AAAAAA" (hex)
216
223
  rotation: 30, # default: 45 (degrees counter-clockwise)
@@ -220,12 +227,49 @@ end
220
227
 
221
228
  Converter adapters (for format-to-PDF conversion) are auto-detected based on MIME type and gem availability. The first available adapter wins, in registration order:
222
229
 
223
- - **text/plain**: Prawn, then HexaPDF
230
+ - **text/plain**: Prawn, then HexaPDF (set `config.converter` to reverse this)
224
231
  - **image/\***: RMagick, then MiniMagick
225
232
  - **Office formats**: LibreOffice (always available if installed)
226
233
  - **application/pdf**: Passthrough (returned unchanged)
227
234
  - **Unknown formats**: Fallback (tries LibreOffice, then returns raw data)
228
235
 
236
+ ### Non-Latin text
237
+
238
+ PDF's built-in fonts only cover the Windows-1252 character set, so text outside it
239
+ (Cyrillic, Greek, CJK, emoji, and many accented forms) cannot be rendered and raises
240
+ `DocPDF::ConversionError`. Point `font_file` at a TrueType font to lift that limit:
241
+
242
+ ```ruby
243
+ DocPDF.configure do |config|
244
+ config.text_options = config.text_options.merge(
245
+ font: "DejaVuSans",
246
+ font_file: "/path/to/DejaVuSans.ttf"
247
+ )
248
+ end
249
+
250
+ DocPDF.convert("resume.txt") # renders Cyrillic, Greek, and more
251
+ ```
252
+
253
+ `font_file` takes a path for the regular weight, or a hash to register several styles:
254
+
255
+ ```ruby
256
+ font_file: {
257
+ normal: "/fonts/DejaVuSans.ttf",
258
+ bold: "/fonts/DejaVuSans-Bold.ttf",
259
+ italic: "/fonts/DejaVuSans-Oblique.ttf",
260
+ bold_italic: "/fonts/DejaVuSans-BoldOblique.ttf"
261
+ }
262
+ ```
263
+
264
+ The same option works for text watermarks, via `config.watermark_options` or per stamp:
265
+
266
+ ```ruby
267
+ DocPDF.watermark("doc.pdf", { text: "ЧЕРНОВИК", font: "DejaVuSans", font_file: "/fonts/DejaVuSans.ttf" })
268
+ ```
269
+
270
+ A font only renders the characters it actually contains. DejaVu covers Latin, Cyrillic
271
+ and Greek but not CJK, so pick a font that covers the scripts you expect.
272
+
229
273
  ## Adapters
230
274
 
231
275
  DocPDF has two types of adapters:
@@ -235,8 +279,8 @@ DocPDF has two types of adapters:
235
279
  | Adapter | Gem | Formats |
236
280
  |---------|-----|---------|
237
281
  | Soffice | None (system) | Word, Excel, PowerPoint, ODF, CSV, HTML, RTF |
238
- | Prawn | `prawn` | Plain text |
239
- | HexaPDF | `hexapdf` | Plain text |
282
+ | Prawn | `prawn` | Plain text (wraps long lines; built-in fonts are Windows-1252 only) |
283
+ | HexaPDF | `hexapdf` | Plain text (wider character support; does not wrap long lines) |
240
284
  | RMagick | `rmagick` | JPEG, PNG, HEIC, WebP |
241
285
  | MiniMagick | `mini_magick` | JPEG, PNG, HEIC, WebP |
242
286
  | Passthrough | None | PDF (returned unchanged) |
@@ -1,5 +1,6 @@
1
1
  require "hexapdf"
2
2
  require_relative "base"
3
+ require_relative "../../font_files"
3
4
 
4
5
  module DocPDF
5
6
  module Adapters
@@ -22,6 +23,7 @@ module DocPDF
22
23
  page_size = normalize_page_size(config.page_size)
23
24
 
24
25
  doc = HexaPDF::Document.new
26
+ register_font(doc, opts[:font], opts[:font_file])
25
27
  page = doc.pages.add(page_size)
26
28
  canvas = page.canvas
27
29
 
@@ -51,6 +53,13 @@ module DocPDF
51
53
 
52
54
  private
53
55
 
56
+ def register_font(doc, name, font_file)
57
+ map = FontFiles.for_hexapdf(font_file)
58
+ return unless map
59
+
60
+ doc.config["font.map"] = (doc.config["font.map"] || {}).merge(name => map)
61
+ end
62
+
54
63
  def normalize_page_size(size)
55
64
  return size if size.is_a?(Symbol)
56
65
 
@@ -1,5 +1,7 @@
1
1
  require "prawn"
2
2
  require_relative "base"
3
+ require_relative "../../prawn_errors"
4
+ require_relative "../../font_files"
3
5
 
4
6
  module DocPDF
5
7
  module Adapters
@@ -7,18 +9,38 @@ module DocPDF
7
9
  class Prawn < Base
8
10
  MIME_TYPES = %w[text/plain].freeze
9
11
 
12
+ # U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR mean "break the
13
+ # line", but Windows-1252 has no room for them, so Prawn's built-in fonts
14
+ # reject them. Text pasted out of word processors and web pages carries
15
+ # them routinely, so translate to a newline instead of failing on it.
16
+ LINE_SEPARATORS = /[

]/
17
+
10
18
  class << self
11
19
  def convert(data, _source_filename)
12
20
  config = DocPDF.configuration
13
21
  opts = config.text_options
14
- content = data.dup.force_encoding("UTF-8")
22
+ content = normalize(data)
15
23
  pdf = ::Prawn::Document.new(page_size: config.page_size, margin: opts[:margins])
24
+ register_font(pdf, opts[:font], opts[:font_file])
16
25
  pdf.font(opts[:font], size: opts[:font_size])
17
26
  pdf.text content, color: opts[:color]
18
27
  pdf.render
19
- rescue ::Prawn::Errors::UnknownFont, ::Prawn::Errors::CannotFit => e
28
+ rescue *PRAWN_ERRORS, ::Encoding::UndefinedConversionError => e
20
29
  raise ConversionError, "Prawn failed to render text to PDF: #{e.message}"
21
30
  end
31
+
32
+ private
33
+
34
+ def register_font(pdf, name, font_file)
35
+ families = FontFiles.for_prawn(font_file)
36
+ return unless families
37
+
38
+ pdf.font_families.update(name => families)
39
+ end
40
+
41
+ def normalize(data)
42
+ data.dup.force_encoding("UTF-8").gsub(LINE_SEPARATORS, "\n")
43
+ end
22
44
  end
23
45
  end
24
46
  end
@@ -1,6 +1,8 @@
1
1
  require "combine_pdf"
2
2
  require "prawn"
3
3
  require_relative "base"
4
+ require_relative "../../prawn_errors"
5
+ require_relative "../../font_files"
4
6
 
5
7
  module DocPDF
6
8
  module Adapters
@@ -10,6 +12,7 @@ module DocPDF
10
12
  def stamp(data, stamps, page_indices: nil)
11
13
  source = ::CombinePDF.parse(data)
12
14
  source_page = source.pages.first
15
+ raise ConversionError, "Cannot stamp a PDF with no pages" unless source_page
13
16
  page_w = source_page[:MediaBox][2].to_f
14
17
  page_h = source_page[:MediaBox][3].to_f
15
18
 
@@ -20,7 +23,7 @@ module DocPDF
20
23
  page << stamp_page if page_indices.nil? || page_indices.include?(idx)
21
24
  end
22
25
  source.to_pdf
23
- rescue ::CombinePDF::ParsingError => e
26
+ rescue ::CombinePDF::ParsingError, *PRAWN_ERRORS, Errno::ENOENT => e
24
27
  raise ConversionError, "CombinePDF failed to stamp PDF: #{e.message}"
25
28
  end
26
29
 
@@ -60,6 +63,13 @@ module DocPDF
60
63
  [img_w, img_h]
61
64
  end
62
65
 
66
+ def register_font(pdf, name, font_file)
67
+ families = FontFiles.for_prawn(font_file)
68
+ return unless families
69
+
70
+ pdf.font_families.update(name => families)
71
+ end
72
+
63
73
  def render_image_stamp(pdf, stamp)
64
74
  page_w = pdf.bounds.width
65
75
  page_h = pdf.bounds.height
@@ -86,6 +96,7 @@ module DocPDF
86
96
  page_w = pdf.bounds.width
87
97
  page_h = pdf.bounds.height
88
98
 
99
+ register_font(pdf, stamp[:font], stamp[:font_file])
89
100
  pdf.font(stamp[:font])
90
101
  font_size = fit_font_size(stamp[:font_size], stamp[:text], pdf, page_w, page_h)
91
102
  text_w = pdf.width_of(stamp[:text], size: font_size)
@@ -1,5 +1,6 @@
1
1
  require "hexapdf"
2
2
  require_relative "base"
3
+ require_relative "../../font_files"
3
4
 
4
5
  module DocPDF
5
6
  module Adapters
@@ -9,6 +10,7 @@ module DocPDF
9
10
  def stamp(data, stamps, page_indices: nil)
10
11
  doc = HexaPDF::Document.new(io: StringIO.new(data))
11
12
  source_page = doc.pages[0]
13
+ raise ConversionError, "Cannot stamp a PDF with no pages" unless source_page
12
14
  page_w = source_page.box.width
13
15
  page_h = source_page.box.height
14
16
 
@@ -21,7 +23,7 @@ module DocPDF
21
23
  end
22
24
 
23
25
  write_to_string(doc)
24
- rescue HexaPDF::Error => e
26
+ rescue HexaPDF::Error, Errno::ENOENT => e
25
27
  raise ConversionError, "HexaPDF failed to stamp PDF: #{e.message}"
26
28
  end
27
29
 
@@ -87,6 +89,7 @@ module DocPDF
87
89
  page_w = page.box.width
88
90
  page_h = page.box.height
89
91
 
92
+ register_font(doc, stamp[:font], stamp[:font_file])
90
93
  font = doc.fonts.add(stamp[:font])
91
94
  glyph_units = font.decode_utf8(stamp[:text]).sum { |g| g.width }
92
95
  font_size = fit_font_size(stamp[:font_size], glyph_units, page_w, page_h)
@@ -115,6 +118,13 @@ module DocPDF
115
118
  end
116
119
  end
117
120
 
121
+ def register_font(doc, name, font_file)
122
+ map = FontFiles.for_hexapdf(font_file)
123
+ return unless map
124
+
125
+ doc.config["font.map"] = (doc.config["font.map"] || {}).merge(name => map)
126
+ end
127
+
118
128
  def write_to_string(doc)
119
129
  io = StringIO.new
120
130
  doc.write(io)
@@ -1,6 +1,7 @@
1
1
  module DocPDF
2
2
  class Configuration
3
3
  attr_accessor :soffice_path,
4
+ :converter,
4
5
  :stamper,
5
6
  :page_size,
6
7
  :text_options,
@@ -8,6 +9,7 @@ module DocPDF
8
9
 
9
10
  TEXT_DEFAULTS = {
10
11
  font: "Courier",
12
+ font_file: nil,
11
13
  font_size: 10,
12
14
  margins: [50, 50, 50, 50],
13
15
  color: "333333",
@@ -15,6 +17,7 @@ module DocPDF
15
17
 
16
18
  WATERMARK_DEFAULTS = {
17
19
  font: "Helvetica",
20
+ font_file: nil,
18
21
  font_size: 72,
19
22
  color: "AAAAAA",
20
23
  rotation: 45,
@@ -22,6 +25,7 @@ module DocPDF
22
25
 
23
26
  def initialize
24
27
  @soffice_path = "soffice"
28
+ @converter = nil
25
29
  @stamper = nil
26
30
  @page_size = "LETTER"
27
31
  @text_options = TEXT_DEFAULTS.dup
@@ -8,12 +8,44 @@ module DocPDF
8
8
  end
9
9
 
10
10
  def resolve(mime_type)
11
+ configured = DocPDF.configuration.converter
12
+ return resolve_configured(configured, mime_type) if configured
13
+
14
+ resolve_registered(mime_type)
15
+ end
16
+
17
+ private
18
+
19
+ def load_adapter(entry)
20
+ require entry[:require_name] if entry[:require_name]
21
+ entry[:loader].call
22
+ end
23
+
24
+ # A configured converter only claims the mime types it registered for, so
25
+ # anything else still falls through to the normal registration order. That
26
+ # keeps config.converter = :hexapdf from breaking Word conversion.
27
+ def resolve_configured(name, mime_type)
28
+ entry = @adapters.find { |e| e[:name] == name }
29
+ unless entry
30
+ valid_names = @adapters.map { |e| e[:name].inspect }.join(", ")
31
+ raise AdapterNotFoundError, "Unknown converter: #{name.inspect}. Valid converters are: #{valid_names}."
32
+ end
33
+
34
+ return resolve_registered(mime_type) unless entry[:mime_types].include?(mime_type)
35
+
36
+ begin
37
+ load_adapter(entry)
38
+ rescue LoadError => e
39
+ raise AdapterNotFoundError, "Converter #{name.inspect} requires gems that are not installed: #{e.message}"
40
+ end
41
+ end
42
+
43
+ def resolve_registered(mime_type)
11
44
  missing_gems = []
12
45
 
13
46
  @adapters.each do |entry|
14
47
  next unless entry[:mime_types].include?(mime_type)
15
- require entry[:require_name] if entry[:require_name]
16
- return entry[:loader].call
48
+ return load_adapter(entry)
17
49
  rescue LoadError
18
50
  missing_gems << entry[:require_name]
19
51
  next
@@ -27,8 +59,6 @@ module DocPDF
27
59
  resolve_fallback
28
60
  end
29
61
 
30
- private
31
-
32
62
  def resolve_fallback
33
63
  entry = @adapters.find { |e| e[:name] == :fallback }
34
64
  raise AdapterNotFoundError, "No converter found and no fallback registered." unless entry
@@ -0,0 +1,35 @@
1
+ module DocPDF
2
+ # Prawn's built-in AFM fonts only cover Windows-1252, so any text outside it
3
+ # fails to render. Pointing an adapter at a TrueType file lifts that limit.
4
+ # Each backend wants the styles keyed differently, so normalize once here.
5
+ module FontFiles
6
+ STYLES = %i[normal bold italic bold_italic].freeze
7
+
8
+ # HexaPDF calls the unstyled variant :none where Prawn calls it :normal.
9
+ HEXAPDF_STYLES = { normal: :none, bold: :bold, italic: :italic, bold_italic: :bold_italic }.freeze
10
+
11
+ class << self
12
+ # Accepts a path for the regular weight, or a hash of styles:
13
+ # "DejaVuSans.ttf"
14
+ # { normal: "...", bold: "...", italic: "...", bold_italic: "..." }
15
+ def normalize(font_file)
16
+ return if font_file.nil?
17
+ return { normal: font_file.to_s } if font_file.is_a?(String) || font_file.is_a?(Pathname)
18
+
19
+ styles = font_file.to_h.transform_keys(&:to_sym).slice(*STYLES)
20
+ styles.empty? ? nil : styles.transform_values(&:to_s)
21
+ end
22
+
23
+ def for_hexapdf(font_file)
24
+ styles = normalize(font_file)
25
+ return unless styles
26
+
27
+ styles.each_with_object({}) { |(style, path), map| map[HEXAPDF_STYLES.fetch(style)] = path }
28
+ end
29
+
30
+ def for_prawn(font_file)
31
+ normalize(font_file)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,14 @@
1
+ require "prawn"
2
+
3
+ module DocPDF
4
+ # Prawn defines every error class directly under StandardError with no shared
5
+ # ancestor, so there is no namespace to rescue. Collecting them lets adapters
6
+ # wrap the whole family in DocPDF::ConversionError rather than leaking Prawn's
7
+ # exceptions to callers who only rescue DocPDF::Error.
8
+ #
9
+ # Only require this from files that already require "prawn"; prawn is optional.
10
+ PRAWN_ERRORS = ::Prawn::Errors.constants
11
+ .map { |name| ::Prawn::Errors.const_get(name) }
12
+ .select { |const| const.is_a?(Class) && const <= StandardError }
13
+ .freeze
14
+ end
@@ -1,3 +1,3 @@
1
1
  module DocPDF
2
- VERSION = "0.1.4"
2
+ VERSION = "0.1.6"
3
3
  end
@@ -46,7 +46,7 @@ module DocPDF
46
46
  merged = STAMP_DEFAULTS.merge(stamp)
47
47
  if merged[:text]
48
48
  wm = DocPDF.configuration.watermark_options
49
- { font: wm[:font], font_size: wm[:font_size], color: wm[:color], rotation: wm[:rotation] }.merge(merged)
49
+ { font: wm[:font], font_file: wm[:font_file], font_size: wm[:font_size], color: wm[:color], rotation: wm[:rotation] }.merge(merged)
50
50
  else
51
51
  merged
52
52
  end
@@ -138,6 +138,8 @@ module DocPDF
138
138
 
139
139
  def stamp_per_page(stamper)
140
140
  page_count = pdf_page_count
141
+ raise ConversionError, "Cannot stamp a PDF with no pages" if page_count.zero?
142
+
141
143
  result = @pdf_bytes
142
144
 
143
145
  build_page_stamp_map(page_count).each do |page_indices, stamps_for_pages|
data/lib/docpdf.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "docpdf/version"
2
2
  require "docpdf/configuration"
3
3
  require "docpdf/errors"
4
+ require "docpdf/font_files"
4
5
  require "docpdf/result"
5
6
  require "docpdf/input_normalizer"
6
7
  require "docpdf/mime_detector"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: docpdf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Velocity Labs, LLC
@@ -38,8 +38,10 @@ files:
38
38
  - lib/docpdf/converter.rb
39
39
  - lib/docpdf/converter_resolver.rb
40
40
  - lib/docpdf/errors.rb
41
+ - lib/docpdf/font_files.rb
41
42
  - lib/docpdf/input_normalizer.rb
42
43
  - lib/docpdf/mime_detector.rb
44
+ - lib/docpdf/prawn_errors.rb
43
45
  - lib/docpdf/result.rb
44
46
  - lib/docpdf/stamper_resolver.rb
45
47
  - lib/docpdf/version.rb