お客様のご指摘から「My Redmine」を改善できました


My Redmine

原田です。今回はお客様のお問い合わせから当社サービスが改善できたことについて書きます。

日々お客様からお問い合わせを頂いております

My Redmineではお客様からwebサポート窓口よりご契約に関することやMy Redmineの利用方法など多岐にわたってお問い合わせを頂いております。お問い合わせの内容によってはサポート担当からRedmine/RedMicaの内部コードを調べる依頼を受けることもあります。

お問い合わせからサービスを改善しました

先日お客様より「チケットをPDF形式でエクスポートしましたが、チケットに埋め込んでいる画像(インライン画像)がPDFに表示されません。」というお問い合わせを頂きました。


エクスポートしたPDFのイメージ(上図のようにチケットに埋め込んだ画像がPDFにも表示されるのが正しい動き)

通常はサポート担当がお問い合わせ内容から現象を確認します。サポート担当には数多くのお問い合わせに対する知識(やり取り)が蓄積されておりそれらを参考にしてお客様にご回答いたします。ただし前例の無いお問い合わせについては社内の見識者に調査を依頼します。今回のお問い合わせも前例が無かったため私に調査依頼がありました。

サポート担当の情報は問題解決するために重要です

サポート担当からは上記お問い合わせ内容と以下の情報を提供して頂きました。

  1. Redmine1.3以降、インライン画像がPDF内に表示できるようになりました( Redmine 1.3新機能紹介: PDFエクスポート機能の改善 | Redmine.JP Blog
  2. My Redmine Gen.2(以下Gen.2と称します) でお客様の仰る現象を再現できました
  3. ただしMy Redmine Gen.1(以下Gen.1と称します) ではお客様の仰る現象が再現できませんでした

My Redmineで提供しているソフトウェアは「RedMica」、現在Gen.1とGen.2でお客様にご提供しているバージョンはRedMica 1.1(Redmine4.1をベースにしRedmine4.2で追加予定の新機能を加えたもの) です。よってインライン画像はPDF内に表示できます。気になったのは上記2・3のGen.2とGen.1で挙動が異なる点でした。

Gen.1とGen.2の違いは添付ファイルの管理方法

My Redmineは従来サービスの「Gen.1」と現行サービスの「Gen.2」 の2つを提供しています。Gen.1とGen.2の違いはGen.2(現行)とGen.1(従来)のサービス内容の違いに記載しております。この違いの一つにストレージ容量を上げていますが、Gen.2はマルチテナントで構成されておりストレージにはAmazon S3(以下S3と称します)を使用し添付ファイルを管理しております(参考資料: My Redmine Gen.2を支えるインフラストラクチャー)。

PDFを作成するGemライブラリがS3に対応していなかったことが原因でした

Visual Studio Codeを用いてデバッグを実施したところS3への添付ファイルの読み書きは適切に動作していますが、PDF形式でエクスポートした時のみS3から該当の添付ファイル(画像ファイル)を読み込んでいないことが分かりました。

ただしPDFエクスポート時に添付ファイルを読み込んでいるのはRedMica本体やS3上のファイルへの読み書きを行っているredmica_s3プラグインではなく、PDFを作成しているGemライブラリrbpdfでした。rbpdfは自サーバー上に存在する画像ファイルかHTTP-GETリクエストでアクセス可能な画像ファイルのみを読み込んでいます。ちなみにredmica_s3プラグインではセキュリティ上の理由からS3へのアクセスはREST API以外を禁止しております。

問題を解決するためにプルリクエストを作成しました

原因がはっきりしましたので、これを解決するために以下2つのいずれかを実施すべきと判断しました。

  1. Gemライブラリrbpdfにプルリクエストを投稿しrbpdfが更新されるのを待つ
  2. redmica_s3プラグインにプルリクエストを投稿し本来rbpdfが実施すべき処理をオーバーライド

最終的には2つとも実施しました。まずredmica_s3プラグインにプルリクエストを投稿し、その後rbpdfにもプルリクエストを提案として投稿しました。これはrbpdfの管理者は当社ではないためプルリクエストのパッチがいつリリースされるか不明であること、そして改善すべき処理の一部はredmica_s3にも追加する必要があるためです。さらにredmica_s3プラグインの管理は当社が行っているのでプルリクエストのパッチをリリースする時期はある程度調整することが可能です。

今回はredmica_s3プラグインにrbpdf側で見直すべき処理を一時的に当プラグインに追加した上で、添付ファイルをインライン画像として扱うためS3オブジェクトの生データを取得するよう改善しました。


redmica_s3プラグイン:プルリクエスト
《クリックで表示》redmica_s3プラグイン:改善パッチ(一部抜粋)
diff --git a/lib/redmica_s3/pdf_patch.rb b/lib/redmica_s3/pdf_patch.rb
new file mode 100644
index 0000000..47a0693
--- /dev/null
+++ b/lib/redmica_s3/pdf_patch.rb
@@ -0,0 +1,180 @@
+require 'open-uri'
+
+module RedmicaS3
+  module PdfPatch
+    extend ActiveSupport::Concern
+
+    included do
+      prepend PrependMethods
+    end
+
+    class_methods do
+    end
+
+    module PrependMethods
+      def self.prepended(base)
+        class << base
+          self.prepend(ClassMethods)
+        end
+      end
+
+      module ClassMethods
+      end
+
+      def get_image_filename(attrname)
+        Redmine::Export::PDF::RDMPdfEncoding.attach(@attachments, attrname, 'UTF-8')
+      end
+
+      protected
+
+      def openHTMLTagHandler(dom, key, cell)
+        tag = dom[key]
+        unless tag['value'] == 'img'
+          return super
+        end
+
+        if !tag['attribute']['src'].nil?
+          tag['attribute']['src'].gsub!(/%([0-9a-fA-F]{2})/){$1.hex.chr}
+
+          img_name = tag['attribute']['src']
+          tag['attribute']['src'] = get_image_filename(img_name)
+
+          tag['width'] ||= 0
+          tag['height'] ||= 0
+          tag['attribute']['align'] = 'bottom'
+          align = 'B'
+
+          prevy = @y
+          xpos = @x
+          xpos +=
+            # eliminate marker spaces
+            if !dom[key - 1].nil?
+              if (dom[key - 1]['value'] == ' ') or !dom[key - 1]['trimmed_space'].nil?
+                -GetStringWidth(32.chr)
+              elsif @rtl and (dom[key - 1]['value'] == '  ')
+                2 * GetStringWidth(32.chr)
+              else
+                0
+              end
+            else
+              0
+            end
+
+          imglink = ''
+          if !@href['url'].nil? and !empty_string(@href['url'])
+            imglink = @href['url']
+            if imglink[0, 1] == '#'
+              # convert url to internal link
+              page = imglink.sub(/^#/, "").to_i
+              imglink = AddLink()
+              SetLink(imglink, 0, page)
+            end
+          end
+          border =
+            if !tag['attribute']['border'].nil? and !tag['attribute']['border'].empty?
+              # currently only support 1 (frame) or a combination of 'LTRB'
+              case tag['attribute']['border']
+              when '0'
+                0
+              when '1'
+                1
+              else
+                tag['attribute']['border']
+              end
+            else
+              0
+            end
+
+          iw = tag['width'] ? getHTMLUnitToUnits(tag['width'], 1, 'px', false) : 0
+          ih = tag['height'] ? getHTMLUnitToUnits(tag['height'], 1, 'px', false) : 0
+
+          # store original margin values
+          l_margin = @l_margin
+          r_margin = @r_margin
+
+          SetLeftMargin(@l_margin + @c_margin)
+          SetRightMargin(@r_margin + @c_margin)
+
+          result_img =
+            proc_image_file(tag['attribute']['src']) do |img_file|
+              Image(img_file, xpos, @y, iw, ih, '', imglink, align, false, 300, '', false, false, border, false, false, true)
+            end
+
+          @y =
+            if result_img or ih != 0
+              case align
+              when 'T'
+                prevy
+              when 'M'
+                (@img_rb_y + prevy - (tag['fontsize'] / @k)) / 2
+              when 'B'
+                @img_rb_y - (tag['fontsize'] / @k)
+              else
+                prevy
+              end
+            else
+              prevy
+            end
+
+          # restore original margin values
+          SetLeftMargin(l_margin)
+          SetRightMargin(r_margin)
+
+          if result_img == false && !img_name.nil?
+            Write(@lasth, File::basename(img_name) + ' ', '', false, '', false, 0, false)
+          end
+        end
+
+        if dom[key]['self'] and dom[key]['attribute']['pagebreakafter']
+          pba = dom[key]['attribute']['pagebreakafter']
+          # check for pagebreak
+          if (pba == 'true') or (pba == 'left') or (pba == 'right')
+            # add a page (or trig AcceptPageBreak() for multicolumn mode)
+            checkPageBreak(@page_break_trigger + 1)
+          end
+          if ((pba == 'left') and ((!@rtl and (@page % 2 == 0)) or (@rtl and (@page % 2 != 0)))) or ((pba == 'right') and ((!@rtl and (@page % 2 != 0)) or (@rtl and (@page % 2 == 0))))
+            # add a page (or trig AcceptPageBreak() for multicolumn mode)
+            checkPageBreak(@page_break_trigger + 1)
+          end
+        end
+        dom
+      end
+
+    end
+
+    def get_image_file(image_uri)
+      #use a temporary file....
+      tmpFile = Tempfile.new(['tmp_', '.img'], self.class.k_path_cache)
+      tmpFile.binmode
+      if image_uri.is_a?(Attachment)
+        tmpFile.write(image_uri.raw_data)
+      else
+        open(image_uri, 'rb') do |read_file|
+          tmpFile.write(read_file.read)
+        end
+      end
+      tmpFile.fsync
+      tmpFile
+    end
+
+    private
+
+    def proc_image_file(src, &block)
+      tmpFile = nil
+      img_file =
+        if src.is_a?(Attachment) || /^http/.match?(src)
+          tmpFile = get_image_file(src)
+          tmpFile.path
+        else
+          src
+        end
+      yield img_file
+    rescue => err
+      logger.error "pdf: Image: error: #{err.message}"
+      false
+    ensure
+      # remove temp files
+      tmpFile.close(true) if tmpFile
+    end
+  end
+end

My Redmineの不具合をお客様と一緒に改善できたことが一番うれしい

redmica_s3プラグインへのプルリクエストはすでにリリースされておりお客様にも動作をご確認頂いております。お客様からのご報告→サポート担当の調査→当方の(開発者による)機能改善という流れが出来ていることでスムーズに問題に対処できたと思います。

不具合は本来あってはならないですけど、どうしても人の手から作り出されるものなので開発者が気付かない不具合が存在する可能性はあり得ます。今後もお客様から今回のようなご報告を頂いた際は、引き続き関係者各位と連携し当社サービスを改善していきます。


My Redmine

こちらの記事もオススメです!
AWS上でクラウドネイティブで再構築した「My Redmine Gen.2」リリース!
My Redmine Gen.2をリリース!クラウドへの対応を進めより良いサービスになりました。
ラズパイを使って室温と外気温の関係を視覚的に調査
想像していた以上に簡単に室温管理・分析のための仕組みを構築することができました。
GoogleスキルショップでGoogle アナリティクスや広告の勉強をしています
Googleの提供するサービスの活用方法を学べるオンラインサービス「スキルショップ」で学習しています。
フレックスタイム制の会社側視点でのメリット
フレックスタイム制の導入で従業員の満足度向上・労務管理の簡素化などが実現できました。
マインドマップを使ってAWSクラウドプラクティショナー試験の勉強をしています
マインドマップ作成ツール「XMind」を使用。マインドマップを用いることで頭の中が整理されより理解を深めることができます。
ファーエンドテクノロジーからのお知らせ(2024/04/17更新)
入門Redmine 第6版 出版記念セミナー「Redmine 8年分の新機能ふりかえり」【2024/4/18開催】
入門Redmine 第6版(2024年3月23日発売)に掲載された新機能に関する内容を執筆者・監修者が紹介します。
My Redmine 初回ご契約で「入門Redmine 第6版」プレゼントのお知らせ
Redmineのクラウドサービス「My Redmine」を初めてご契約いただいたお客様にRedmine解説書「入門Redmine 第6版」を進呈いたします。
2024年度ブランドパートナーに島根県在住のモデル ユイさんを継続起用
ユイさん(モデルスタジオミューズ所属)をファーエンドテクノロジーの2024年度ブランドパートナーとして継続して起用します。
My Redmine スタンダードプランおよびAdminサポートデスクプランの料金改定のお知らせ【2024年4月ご利用分より】
2024年4月ご利用分より、My Redmine スタンダードプラン(民間企業・個人向け及び官公庁向け)とAdminサポートデスクプランの料金を改定いたします。
Redmineの最新情報をメールでお知らせする「Redmine News」配信中
新バージョンやセキュリティ修正のリリース情報、そのほか最新情報を迅速にお届け