Module: Sevgi::Sundries::Export

Extended by:
Export
Included in:
Export
Defined in:
lib/sevgi/sundries/export/native.rb,
lib/sevgi/sundries/export/system.rb

Overview

Exports SVG content and post-processes PDF output.

Defined Under Namespace

Modules: Renderer

Constant Summary collapse

ExportError =

Raised when SVG export or PDF post-processing cannot be completed.

Class.new(Error)
DEFAULT_DPI =

Default SVG CSS pixel density.

96.0
AVAILABLE =

Supported export format names mapped to file extensions.

(EXTENSIONS = {
".pdf" => :pdf,
".png" => :png
      }.freeze)
.invert
.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil) {|svg| ... } ⇒ Object

Exports SVG source to a PDF or PNG file using librsvg and Cairo.

Parameters:

  • svg (String)

    SVG source content

  • output (String, #to_s)

    output file path

  • format (Symbol, String, nil) (defaults to: nil)

    explicit output format, or nil to infer from output extension

  • width (Numeric, nil) (defaults to: nil)

    target width in output pixels for PNG, or CSS pixels before PDF point conversion

  • height (Numeric, nil) (defaults to: nil)

    target height in output pixels for PNG, or CSS pixels before PDF point conversion

  • dpi (Numeric) (defaults to: DEFAULT_DPI)

    CSS pixel density used for absolute SVG units and PDF point conversion

  • css (String, nil) (defaults to: nil)

    CSS inserted before the closing svg tag before rendering

Yields:

  • (svg)

    optional source transformation applied before rendering

Yield Parameters:

  • svg (String)

    SVG source after optional CSS injection

Yield Returns:

  • (String)

    SVG source to render

Returns:

  • (Object)

    the original output argument

Raises:

  • (Sevgi::ArgumentError)

    when SVG content is not a string or output is blank

  • (Sevgi::Sundries::Export::ExportError)

    when format, SVG parsing, SVG dimensions, or render dimensions are invalid



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/sevgi/sundries/export/native.rb', line 33

def self.call(svg, output, format: nil, width: nil, height: nil, dpi: DEFAULT_DPI, css: nil, &block)
  ArgumentError.("SVG content must be a String") unless svg.is_a?(String)
  ArgumentError.("Export output must be provided") if output.nil? || output.to_s.strip.empty?

  svg = inject(svg, css) if css && !css.strip.empty?
  svg = block.call(svg) if block
  ArgumentError.("SVG content must be a String") unless svg.is_a?(String)

  format = format_for!(format, output)
  renderer = Renderer.method(format)

  begin
    handle = Rsvg::Handle.new_from_data(svg)

    iw, ih = intrinsic_size(handle)
    ExportError.("Invalid SVG dimensions") if iw <= 0 || ih <= 0

    scale = dpi / DEFAULT_DPI

    iw *= scale
    ih *= scale

    tw, th = target_size(iw, ih, width, height)
    ExportError.("Invalid export dimensions") unless target_size?(format, tw, th)

    renderer.call(
      handle: handle,
      output: output.to_s,
      iw: iw,
      ih: ih,
      tw: tw,
      th: th,
      dpi: dpi
    )
  rescue Rsvg::Error, Cairo::Error => e
    ExportError.("Render error: #{e.message}")
  end

  output
end

Instance Method Details

#a5_on_a4(infile, outfile) ⇒ Sevgi::Function::Shell::Result

Places a single A5 PDF page twice on an A4 landscape sheet with pdfcpu.

Parameters:

  • infile (String)

    source PDF file path

  • outfile (String)

    destination PDF file path

Returns:

  • (Sevgi::Function::Shell::Result)

    command result

Raises:

  • (Sevgi::Error)

    when pdfcpu is missing or the command fails

  • (Errno::ENOENT)

    when the executable cannot be spawned

See Also:



16
# File 'lib/sevgi/sundries/export/system.rb', line 16

def a5_on_a4(infile, outfile) = F.sh!("pdfcpu", "nup", "--", "form:A4L, border:off", outfile, "2", infile)

#a5_on_a4!(infile) ⇒ void

This method returns an undefined value.

Replaces a PDF file with an A5-on-A4 layout generated by pdfcpu.

Parameters:

  • infile (String)

    PDF file path to modify

Raises:

  • (Sevgi::Error)

    when pdfcpu is missing or the command fails

  • (Errno::ENOENT)

    when the executable cannot be spawned



23
24
25
26
27
28
29
# File 'lib/sevgi/sundries/export/system.rb', line 23

def a5_on_a4!(infile)
  temp = Tempfile.new(%w[output .pdf], File.dirname(infile))
  a5_on_a4(infile, temp.path)
  FileUtils.mv(temp.path, infile)
ensure
  temp&.close!
end

#format_for!(format, output) ⇒ Symbol

Resolves the export format from an explicit value or output extension.

Parameters:

  • format (Symbol, String, nil)

    explicit format

  • output (String, #to_s)

    output path

Returns:

  • (Symbol)

    resolved format

Raises:



87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/sevgi/sundries/export/native.rb', line 87

def format_for!(format, output)
  if format
    format = format.to_sym
    ExportError.("Unsupported export format: #{format}") unless AVAILABLE.key?(format)

    format
  else
    ext = File.extname(output.to_s).downcase
    ExportError.("Unrecognized file extension: #{ext}") unless EXTENSIONS.key?(ext)

    EXTENSIONS[ext]
  end
end

#inject(svg, css) ⇒ String

Inserts CSS before the closing svg tag.

Parameters:

  • svg (String)

    SVG source content

  • css (String)

    CSS source content

Returns:

  • (String)

    SVG source with an added style element when a closing svg tag is present



105
# File 'lib/sevgi/sundries/export/native.rb', line 105

def inject(svg, css) = svg.sub("</svg>", "<style>#{css}</style></svg>")

#inkscape(infile, outfile = nil, format: nil, background: "#ffffff", opacity: 1, width: nil, height: nil, id: nil, page: nil, css: nil) ⇒ Sevgi::Function::Shell::Result

Exports an SVG file through Inkscape.

Parameters:

  • infile (String)

    source SVG file path

  • outfile (String, nil) (defaults to: nil)

    output path, defaulting to infile with a .pdf extension

  • format (Symbol, String, nil) (defaults to: nil)

    explicit output format, or nil to infer from outfile

  • background (String, nil) (defaults to: "#ffffff")

    export background color

  • opacity (Numeric, nil) (defaults to: 1)

    export background opacity

  • width (Numeric, nil) (defaults to: nil)

    target export width

  • height (Numeric, nil) (defaults to: nil)

    target export height

  • id (String, nil) (defaults to: nil)

    SVG element id to export

  • page (Integer, String, nil) (defaults to: nil)

    page selector passed to Inkscape

  • css (String, nil) (defaults to: nil)

    CSS inserted before exporting

Returns:

  • (Sevgi::Function::Shell::Result)

    command result

Raises:

  • (Sevgi::Sundries::Export::ExportError)

    when format or output extension is unsupported

  • (Sevgi::Error)

    when Inkscape is missing or the command fails

  • (Errno::ENOENT)

    when the executable cannot be spawned

See Also:



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/sevgi/sundries/export/system.rb', line 47

def inkscape(
  infile,
  outfile = nil,
  format: nil,
  background: "#ffffff",
  opacity: 1,
  width: nil,
  height: nil,
  id: nil,
  page: nil,
  css: nil
)
  infile = File.expand_path(infile)
  outfile ||= F.subext(".pdf", infile)
  outfile = File.expand_path(outfile)
  format = format_for!(format, outfile)

  if css
    temp = Tempfile.new(%w[input .svg], File.dirname(infile))
    ::File.write(temp.path, inject(::File.read(infile), css))
    infile = temp.path
  end

  F.sh!(
    *[
      "inkscape",
      "--batch-process",
      "--actions=select-by-class:text,object-to-path",
      "--export-type=#{format}",
      ("--export-background=#{background}" if background),
      ("--export-background-opacity=#{opacity}" if opacity),
      ("--export-width=#{width}" if width),
      ("--export-height=#{height}" if height),
      ("--export-id=#{id}" if id),
      ("--export-id-only" if id),
      ("--export-page=#{page}" if page),
      "--export-filename=#{outfile}",
      infile
    ].compact
  )
ensure
  temp&.close!
end

#rsvg(infile, outfile = nil, format: nil, background: "#ffffff", width: nil, height: nil, id: nil, css: nil) ⇒ Sevgi::Function::Shell::Result

Exports an SVG file through rsvg-convert.

Parameters:

  • infile (String)

    source SVG file path

  • outfile (String, nil) (defaults to: nil)

    output path, defaulting to infile with a .pdf extension

  • format (Symbol, String, nil) (defaults to: nil)

    explicit output format, or nil to infer from outfile

  • background (String, nil) (defaults to: "#ffffff")

    export background color

  • width (Numeric, nil) (defaults to: nil)

    target export width

  • height (Numeric, nil) (defaults to: nil)

    target export height

  • id (String, nil) (defaults to: nil)

    SVG element id to export

  • css (String, nil) (defaults to: nil)

    CSS inserted before exporting

Returns:

  • (Sevgi::Function::Shell::Result)

    command result

Raises:

  • (Sevgi::Sundries::Export::ExportError)

    when format or output extension is unsupported

  • (Sevgi::Error)

    when rsvg-convert is missing or the command fails

  • (Errno::ENOENT)

    when the executable cannot be spawned

See Also:



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/sevgi/sundries/export/system.rb', line 105

def rsvg(
  infile,
  outfile = nil,
  format: nil,
  background: "#ffffff",
  width: nil,
  height: nil,
  id: nil,
  css: nil
)
  infile = File.expand_path(infile)
  outfile ||= F.subext(".pdf", infile)
  outfile = File.expand_path(outfile)
  format = format_for!(format, outfile)

  if css
    temp = Tempfile.new(%w[input .svg], File.dirname(infile))
    ::File.write(temp.path, inject(::File.read(infile), css))
    infile = temp.path
  end

  F.sh!(
    *[
      "rsvg-convert",
      "--format=#{format}",
      ("--background-color=#{background}" if background),
      ("--width=#{width}" if width),
      ("--height=#{height}" if height),
      ("--export-id=#{id}" if id),
      "--output=#{outfile}",
      infile
    ].compact
  )
ensure
  temp&.close!
end

#stamp(infile, outfile, stamp:, placeholder:) ⇒ Boolean

Replaces a placeholder text object in a PDF stream.

Parameters:

  • infile (String)

    source PDF file path

  • outfile (String)

    destination PDF file path

  • stamp (String)

    replacement text

  • placeholder (String)

    placeholder text to replace

Returns:

  • (Boolean)

    true when a matching placeholder was replaced

Raises:



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/sevgi/sundries/export/native.rb', line 114

def stamp(infile, outfile, stamp:, placeholder:)
  doc = HexaPDF::Document.open(infile)
  stamped = false

  doc.pages.each do |page|
    Array(page[:Contents]).each do |ref|
      obj = doc.object(ref)
      next unless obj.respond_to?(:stream)

      data = obj.stream
      next unless data.include?("(#{placeholder})")

      data = data.gsub(
        %r{1 1 1 rg (BT\s+.*?/\S+ \d+ Tf\s+)\(#{Regexp.escape(placeholder)}\)Tj}m,
        "0.101961 0.101961 0.101961 rg \\1(#{stamp})Tj"
      )

      obj.stream = data
      obj.set_filter(:FlateDecode)
      stamped = true
    end
  end

  doc.write(outfile, optimize: true) if stamped
  stamped
rescue HexaPDF::Error, ::SystemCallError => e
  ExportError.("PDF stamp error: #{e.message}")
end

#stamp!(infile, stamp:, placeholder:) ⇒ Boolean

Replaces a placeholder text object inside a PDF file in place.

Parameters:

  • infile (String)

    PDF file path to modify

  • stamp (String)

    replacement text

  • placeholder (String)

    placeholder text to replace

Returns:

  • (Boolean)

    true when a matching placeholder was replaced

Raises:



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/sevgi/sundries/export/native.rb', line 149

def stamp!(infile, stamp:, placeholder:)
  temp = Tempfile.new(%w[stamp .pdf], File.dirname(infile))
  stamped = stamp(infile, temp.path, stamp:, placeholder:)
  if stamped
    if File.exist?(temp.path) && File.empty?(temp.path)
      warn("Skipping 0 byte file which was produced during stamping")
    else
      FileUtils.mv(temp.path, infile)
    end
  end

  stamped
rescue ::SystemCallError => e
  ExportError.("PDF stamp error: #{e.message}")
ensure
  temp&.close!
end

#unite(sources, outfile) ⇒ Sevgi::Function::Shell::Result

Merges PDF files with pdfunite.

Parameters:

  • sources (Array<String>)

    source PDF file paths

  • outfile (String)

    destination PDF file path

Returns:

  • (Sevgi::Function::Shell::Result)

    command result

Raises:

  • (Sevgi::Error)

    when pdfunite is missing or the command fails

  • (Errno::ENOENT)

    when the executable cannot be spawned

See Also:



149
# File 'lib/sevgi/sundries/export/system.rb', line 149

def unite(sources, outfile) = F.sh!("pdfunite", *sources, outfile)