xkraty / doc2pdf

Given a .doc file containing some placeholders in the form of the pattern {placeholder_here} and a replacement strategy (a mapping between placeholder texts and the content to be inserted), it applies the replacements and converts the document into a .pdf.
MIT License
0 stars 0 forks source link

Implement mapping replacement #4

Closed olistik closed 3 years ago

olistik commented 3 years ago

TOOD: define some simple examples to show the expected behaviour of this feature.

xkraty commented 3 years ago

The goal to achieve is to implement an easy to use readable tag for the end user that will be mapped to a decorated field.

For example let's say we have a Subscription model related to a Participant and this tag to replace {Subscription_date} {Participant_first_name}

Example 1

We want to print the subscription contract given the Subscription instance

in this scenario we have the Subscription @instance so we'd need to replace as:

Example 2

We want to print the participant details given the Participant instance

Notice the difference here we don't need to call participant relation as we already have his instance

olistik commented 3 years ago

@xkraty Following our call, one idea that could help us be flexible is to pass an object that responds to call(key:) that is invoked for each occurrence found:

diff --git a/lib/doc2pdf.rb b/lib/doc2pdf.rb
index b3811c4..aea28d0 100644
--- a/lib/doc2pdf.rb
+++ b/lib/doc2pdf.rb
@@ -18,20 +18,20 @@ module Doc2pdf
     end.flatten
   end

-  def self.replace!(document:)
+  def self.replace!(document:, replacer:)
     DocumentTraversal.new(document: document).each do |item|
       search(item.text).each do |occurrence|
-        item.substitute(occurrence, "QUALCOSA")
+        item.substitute(occurrence, replacer.call(key: occurrence))
       end
     end

     document
   end

-  def self.replace_and_save!(document:, output_base_path:)
+  def self.replace_and_save!(document:, output_base_path:, replacer:)
     FileUtils.mkdir_p(File.dirname(output_base_path))

-    replace!(document: document)
+    replace!(document: document, replacer: replacer)

     doc_path = "#{output_base_path}.doc"
     pdf_path = "#{output_base_path}.pdf"

and this allows us to do something like this:

Define a simple exception:

class KeyNotFound < StandardError; end

Some data to play with:

participant = Struct.new(:first_name, :birthplace).new('Maurizio', 'Sesto San Giovanni')
subscription_contract = Struct.new(:contract_name, :participant).new('Fornitura energia elettrica', participant)

subscription_contract.contract_name # => 'Fornitura energia elettrica'
subscription_contract.participant.first_name # => 'Maurizio'
subscription_contract.participant.birthplace # => 'Sesto San Giovanni'

With mapping, straightforward with no method missing

The suggested approach. ^_^

class MyReplacer
  def initialize(subscription_contract:)
    self.subscription_contract = subscription_contract
  end

  def call(key:)
    case key
    when 'nome' then subscription_contract.contract_name
    when 'nome_partecipante' then subscription_contract.participant.first_name
    when 'luogo_nascita_partecipante' then subscription_contract.participant.birthplace
    else
      raise KeyNotFound.new(key: key)
    end
  end

  private

  attr_accessor :subscription_contract
end
replacer.call(key: 'nome') # => 'Fornitura energia elettrica'
replacer.call(key: 'nome_partecipante') # => 'Maurizio'
replacer.call(key: 'luogo_nascita_partecipante') # => 'Sesto San Giovanni'
replacer.call(key: 'wat') # => KeyNotFound ({:key=>"wat", :replacer_class=>MyReplacer})

No mapping, with method missing and an override

A simple mapping can be implemented with proper method names (the overrides).

class MyReplacer
  def initialize(subscription_contract:)
    self.subscription_contract = subscription_contract
  end

  def call(key:)
    send(key)
  end

  private

  def contract_name
    subscription_contract.contract_name
  end

  def method_missing(m, *args, &block)
    m.to_s.split('.').each_with_object([subscription_contract]) do |method_name, memo|
      memo[0] = memo[0].send(method_name)
    end[0]  
  end

  attr_accessor :subscription_contract
end
replacer = MyReplacer.new(subscription_contract: subscription_contract)

replacer.call(key: 'contract_name') # => 'Fornitura energia elettrica'
replacer.call(key: 'participant.first_name') # => 'Maurizio'
replacer.call(key: 'participant.birthplace') # => 'Sesto San Giovanni'

With hash mapping, method missing and overrides

class MyReplacer
  MAPPING = {
    'nome' => 'contract_name',
    'nome_partecipante' => 'participant.first_name',
    'luogo_nascita_partecipante' => 'participant.birthplace',
  }.freeze

  def initialize(subscription_contract:)
    self.subscription_contract = subscription_contract
  end

  def call(key:)
    unless MAPPING.keys.include?(key)
      raise KeyNotFound.new(key: key, replacer_class: self.class)
    end

    send(MAPPING[key])
  end

  private

  def ciccio
    'pasticcio'
  end

  def method_missing(m, *args, &block)
    m.to_s.split('.').each_with_object([subscription_contract]) do |method_name, memo|
      memo[0] = memo[0].send(method_name)
    end[0]  
  end

  attr_accessor :subscription_contract
end
replacer = MyReplacer.new(subscription_contract: subscription_contract)

replacer.call(key: 'nome') # => 'Fornitura energia elettrica'
replacer.call(key: 'nome_partecipante') # => 'Maurizio'
replacer.call(key: 'luogo_nascita_partecipante') # => 'Sesto San Giovanni'

replacer.call(key: 'ciccio') # => 'pasticcio'

replacer.call(key: 'wat') # => KeyNotFound ({:key=>"wat", :replacer_class=>MyReplacer})
xkraty commented 3 years ago

It would be interesting to compare performance of the first and third solution, I mean I like how clean the first approach is and also I like how would be straightforward the usage of the third solution with just a simple mapping but with the ability to extend.

olistik commented 3 years ago

@xkraty

It would be interesting to compare performance of the first and third solution, I mean I like how clean the first approach is and also I like how would be straightforward the usage of the third solution with just a simple mapping but with the ability to extend.

They're not mutually exclusive. Regarding this gem, we just have to implement this patch:

diff --git a/lib/doc2pdf.rb b/lib/doc2pdf.rb
index b3811c4..aea28d0 100644
--- a/lib/doc2pdf.rb
+++ b/lib/doc2pdf.rb
@@ -18,20 +18,20 @@ module Doc2pdf
     end.flatten
   end

-  def self.replace!(document:)
+  def self.replace!(document:, replacer:)
     DocumentTraversal.new(document: document).each do |item|
       search(item.text).each do |occurrence|
-        item.substitute(occurrence, "QUALCOSA")
+        item.substitute(occurrence, replacer.call(key: occurrence))
       end
     end

     document
   end

-  def self.replace_and_save!(document:, output_base_path:)
+  def self.replace_and_save!(document:, output_base_path:, replacer:)
     FileUtils.mkdir_p(File.dirname(output_base_path))

-    replace!(document: document)
+    replace!(document: document, replacer: replacer)

     doc_path = "#{output_base_path}.doc"
     pdf_path = "#{output_base_path}.pdf"

And let the consumers decide the approach they like the most, using hash maps, method missing and whatnot. :-)

xkraty commented 3 years ago

I do agree with you, we could ship the gem with a ready to use method missing solution or DIY, I like it!

olistik commented 3 years ago

@xkraty

I do agree with you, we could ship the gem with a ready to use method missing solution or DIY, I like it!

I'd just provide the simplest possible implementation in the README.md and let the users roll their own:

replacer = lambda ->(key:) { "#{key}_foo" }

# or

class MyReplacer
  def call(key:)
    "#{key}_foo"
  end
end

replacer = MyReplacer.new

# and then

Doc2pdf.replace!(document: document: replacer: replacer)
Doc2pdf.replace_and_save!(document: document, replacer: replacer, output_base_path: './tmp')

What do you think?

xkraty commented 3 years ago

Sounds great! bitmoji

olistik commented 3 years ago

@xkraty We can ship this replacer:

class SimpleReplacer
  def initialize(resource)
    @resource = resource
  end

  def call(key:)
    send(key)
  end

  private

  def method_missing(m, *args, &block)
    m.to_s.split('.').inject(@resource) do |memo, method_name|
      memo.send(method_name)
    end
  end
end
replacer = SimpleReplacer.new('Foo')
replacer.call(key: 'length.class.to_s.length') # => 7
replacer.call(key: 'asd') # => NoMethodError (undefined method `asd' for "Foo":String)

Along with the warning:

With great powers, comes great responsibility.

As, for one, a typo in the definition of method_missing ends up with a SystemStackError (stack level too deep) exception. 😅

xkraty commented 3 years ago

So this is without the defined mapping? It just rely on resource methods?