docpdf 0.1.5 → 0.1.7

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: 43cbb73b198ee9a4c207afbdf359ffa4ce79b2c66e372169049fe622cce4f562
4
- data.tar.gz: f08f6d9e7fd5b82a9b69f4e5402b94c4ab8a627c7468ad36373d6842458f2299
3
+ metadata.gz: 4fa01d67dead11a1f62eb4ff7f6f387599b97ec99d0c91bd5644e60e98fd26a6
4
+ data.tar.gz: 1dc447f55133af1a3ff94ad3aaaeca8014a1560cdd6db5cf2d85ab30220afa1e
5
5
  SHA512:
6
- metadata.gz: 3612856e07798bbee41cd5a2dea00dd21ea67aadd2b4c8412fe2afd4c13dc9f91a334833373ba8f099b7bae1392e6756736da848085a2f2e3a3a04ef962568f0
7
- data.tar.gz: 53b3025fab9d38dc1bf0d8b960b42088d49b73e4a05f79b6025b18bb9f912bde8da2d0dfacf571bc2b11f12e6c47b9266ef3f30d637e4c1fe1ae59ec937699e4
6
+ metadata.gz: 3cfa330a65d41f8c24866b04da28d15a795df5c826f2459a9ae1c46c86c470147e92c6ff75952579d3bfd3b6fd4804747a712b868fc829102fab3ece963d0a2d
7
+ data.tar.gz: aa22481e4dfbb0966c9f95b1ecd5924680c491729a3c0ac8b873d2aca03c49b4889a936ce57400a3681594c5ac18078da66c8fa78b20cbed9da211316115f883
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.7] - 2026-10-08
4
+
5
+ ### Fixed
6
+ - The HexaPDF stamper raised `ConversionError` on PDFs with spec violations HexaPDF cannot correct, even though viewers render them fine. One example is a tagged PDF exported from Canva with a structure element missing its parent. The stamper now corrects what HexaPDF can and writes the rest as it came in, the way the `hexapdf` CLI does.
7
+ - `DocPDF.convert` raised `NameError: uninitialized constant DocPDF::InputNormalizer::Pathname` on Ruby 3.3 and 3.4 when nothing else had loaded `pathname`. Bundler and Rails both load it, so only plain Ruby scripts were affected. `DocPDF.watermark` with a file path and `font_file` given as a hash hit the same error. DocPDF now requires `pathname` itself.
8
+
9
+ ## [0.1.6] - 2026-09-18
10
+
11
+ ### Added
12
+ - `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.
13
+
3
14
  ## [0.1.5] - 2026-09-18
4
15
 
5
16
  ### Added
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
 
@@ -208,6 +208,7 @@ DocPDF.configure do |config|
208
208
  # Plain text file conversion defaults
209
209
  config.text_options = {
210
210
  font: "Helvetica", # default: "Courier"
211
+ font_file: nil, # default: nil (see "Non-Latin text" below)
211
212
  font_size: 12, # default: 10
212
213
  margins: [72, 72, 72, 72], # default: [50, 50, 50, 50] (points: top, right, bottom, left)
213
214
  color: "000000", # default: "333333" (hex)
@@ -216,6 +217,7 @@ DocPDF.configure do |config|
216
217
  # Text watermark defaults (per-stamp options override these)
217
218
  config.watermark_options = {
218
219
  font: "Times", # default: "Helvetica"
220
+ font_file: nil, # default: nil (see "Non-Latin text" below)
219
221
  font_size: 96, # default: 72
220
222
  color: "FF0000", # default: "AAAAAA" (hex)
221
223
  rotation: 30, # default: 45 (degrees counter-clockwise)
@@ -231,6 +233,43 @@ Converter adapters (for format-to-PDF conversion) are auto-detected based on MIM
231
233
  - **application/pdf**: Passthrough (returned unchanged)
232
234
  - **Unknown formats**: Fallback (tries LibreOffice, then returns raw data)
233
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
+
234
273
  ## Adapters
235
274
 
236
275
  DocPDF has two types of adapters:
@@ -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,6 +1,7 @@
1
1
  require "prawn"
2
2
  require_relative "base"
3
3
  require_relative "../../prawn_errors"
4
+ require_relative "../../font_files"
4
5
 
5
6
  module DocPDF
6
7
  module Adapters
@@ -20,6 +21,7 @@ module DocPDF
20
21
  opts = config.text_options
21
22
  content = normalize(data)
22
23
  pdf = ::Prawn::Document.new(page_size: config.page_size, margin: opts[:margins])
24
+ register_font(pdf, opts[:font], opts[:font_file])
23
25
  pdf.font(opts[:font], size: opts[:font_size])
24
26
  pdf.text content, color: opts[:color]
25
27
  pdf.render
@@ -29,6 +31,13 @@ module DocPDF
29
31
 
30
32
  private
31
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
+
32
41
  def normalize(data)
33
42
  data.dup.force_encoding("UTF-8").gsub(LINE_SEPARATORS, "\n")
34
43
  end
@@ -2,6 +2,7 @@ require "combine_pdf"
2
2
  require "prawn"
3
3
  require_relative "base"
4
4
  require_relative "../../prawn_errors"
5
+ require_relative "../../font_files"
5
6
 
6
7
  module DocPDF
7
8
  module Adapters
@@ -62,6 +63,13 @@ module DocPDF
62
63
  [img_w, img_h]
63
64
  end
64
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
+
65
73
  def render_image_stamp(pdf, stamp)
66
74
  page_w = pdf.bounds.width
67
75
  page_h = pdf.bounds.height
@@ -88,6 +96,7 @@ module DocPDF
88
96
  page_w = pdf.bounds.width
89
97
  page_h = pdf.bounds.height
90
98
 
99
+ register_font(pdf, stamp[:font], stamp[:font_file])
91
100
  pdf.font(stamp[:font])
92
101
  font_size = fit_font_size(stamp[:font_size], stamp[:text], pdf, page_w, page_h)
93
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
@@ -21,7 +22,7 @@ module DocPDF
21
22
  page.canvas(type: :overlay).xobject(stamp_form, at: [0, 0]) if page_indices.nil? || page_indices.include?(idx)
22
23
  end
23
24
 
24
- write_to_string(doc)
25
+ write_to_string_leniently(doc)
25
26
  rescue HexaPDF::Error, Errno::ENOENT => e
26
27
  raise ConversionError, "HexaPDF failed to stamp PDF: #{e.message}"
27
28
  end
@@ -88,6 +89,7 @@ module DocPDF
88
89
  page_w = page.box.width
89
90
  page_h = page.box.height
90
91
 
92
+ register_font(doc, stamp[:font], stamp[:font_file])
91
93
  font = doc.fonts.add(stamp[:font])
92
94
  glyph_units = font.decode_utf8(stamp[:text]).sum { |g| g.width }
93
95
  font_size = fit_font_size(stamp[:font_size], glyph_units, page_w, page_h)
@@ -116,11 +118,30 @@ module DocPDF
116
118
  end
117
119
  end
118
120
 
119
- def write_to_string(doc)
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
+
128
+ def write_to_string(doc, **write_options)
120
129
  io = StringIO.new
121
- doc.write(io)
130
+ doc.write(io, **write_options)
122
131
  io.string
123
132
  end
133
+
134
+ # The caller's PDF may break the spec in ways HexaPDF cannot repair, like a
135
+ # tagged PDF whose structure elements have no parent. Viewers open those
136
+ # fine, so correct what can be corrected and write the rest as it came in,
137
+ # the way the hexapdf CLI does. Objects are validated one at a time because
138
+ # Document#validate stops at the first one it cannot correct, and the stamp
139
+ # form only becomes an indirect object when validation reaches its page.
140
+ def write_to_string_leniently(doc)
141
+ doc.trailer.validate(auto_correct: true)
142
+ doc.each { |obj| obj.validate(auto_correct: true) }
143
+ write_to_string(doc, validate: false)
144
+ end
124
145
  end
125
146
  end
126
147
  end
@@ -9,6 +9,7 @@ module DocPDF
9
9
 
10
10
  TEXT_DEFAULTS = {
11
11
  font: "Courier",
12
+ font_file: nil,
12
13
  font_size: 10,
13
14
  margins: [50, 50, 50, 50],
14
15
  color: "333333",
@@ -16,6 +17,7 @@ module DocPDF
16
17
 
17
18
  WATERMARK_DEFAULTS = {
18
19
  font: "Helvetica",
20
+ font_file: nil,
19
21
  font_size: 72,
20
22
  color: "AAAAAA",
21
23
  rotation: 45,
@@ -0,0 +1,37 @@
1
+ require "pathname"
2
+
3
+ module DocPDF
4
+ # Prawn's built-in AFM fonts only cover Windows-1252, so any text outside it
5
+ # fails to render. Pointing an adapter at a TrueType file lifts that limit.
6
+ # Each backend wants the styles keyed differently, so normalize once here.
7
+ module FontFiles
8
+ STYLES = %i[normal bold italic bold_italic].freeze
9
+
10
+ # HexaPDF calls the unstyled variant :none where Prawn calls it :normal.
11
+ HEXAPDF_STYLES = { normal: :none, bold: :bold, italic: :italic, bold_italic: :bold_italic }.freeze
12
+
13
+ class << self
14
+ # Accepts a path for the regular weight, or a hash of styles:
15
+ # "DejaVuSans.ttf"
16
+ # { normal: "...", bold: "...", italic: "...", bold_italic: "..." }
17
+ def normalize(font_file)
18
+ return if font_file.nil?
19
+ return { normal: font_file.to_s } if font_file.is_a?(String) || font_file.is_a?(Pathname)
20
+
21
+ styles = font_file.to_h.transform_keys(&:to_sym).slice(*STYLES)
22
+ styles.empty? ? nil : styles.transform_values(&:to_s)
23
+ end
24
+
25
+ def for_hexapdf(font_file)
26
+ styles = normalize(font_file)
27
+ return unless styles
28
+
29
+ styles.each_with_object({}) { |(style, path), map| map[HEXAPDF_STYLES.fetch(style)] = path }
30
+ end
31
+
32
+ def for_prawn(font_file)
33
+ normalize(font_file)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,3 +1,5 @@
1
+ require "pathname"
2
+
1
3
  module DocPDF
2
4
  class InputNormalizer
3
5
  attr_reader :data, :filename, :source_filename, :mime_type
@@ -1,3 +1,3 @@
1
1
  module DocPDF
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.7"
3
3
  end
@@ -1,3 +1,5 @@
1
+ require "pathname"
2
+
1
3
  module DocPDF
2
4
  class Watermarker
3
5
  POSITIONS = %i[center top bottom left right top_left top_right bottom_left bottom_right].freeze
@@ -46,7 +48,7 @@ module DocPDF
46
48
  merged = STAMP_DEFAULTS.merge(stamp)
47
49
  if merged[:text]
48
50
  wm = DocPDF.configuration.watermark_options
49
- { font: wm[:font], font_size: wm[:font_size], color: wm[:color], rotation: wm[:rotation] }.merge(merged)
51
+ { font: wm[:font], font_file: wm[:font_file], font_size: wm[:font_size], color: wm[:color], rotation: wm[:rotation] }.merge(merged)
50
52
  else
51
53
  merged
52
54
  end
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.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Velocity Labs, LLC
@@ -38,6 +38,7 @@ 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
43
44
  - lib/docpdf/prawn_errors.rb