changeset 1657:66e5fcdf5597

pdf viewer is working
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 18 Nov 2020 11:19:09 +0100
parents 4cdc297be5a6
children 18384efed33d
files Applications/StoneWebViewer/WebApplication/app.js Applications/StoneWebViewer/WebApplication/index.html Applications/StoneWebViewer/WebApplication/pdf-viewer.js Applications/StoneWebViewer/WebApplication/print.js Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp OrthancStone/Sources/Loaders/SeriesThumbnailsLoader.cpp
diffstat 6 files changed, 286 insertions(+), 49 deletions(-) [+]
line wrap: on
line diff
--- a/Applications/StoneWebViewer/WebApplication/app.js	Tue Nov 17 11:18:53 2020 +0100
+++ b/Applications/StoneWebViewer/WebApplication/app.js	Wed Nov 18 11:19:09 2020 +0100
@@ -25,6 +25,9 @@
 var STUDY_DESCRIPTION = '0008,1030';
 var STUDY_DATE = '0008,0020';
 
+// Registry of the PDF series for which the instance metadata is still waiting
+var pendingSeriesPdf_ = {};
+
 
 function getParameterFromUrl(key) {
   var url = window.location.search.substring(1);
@@ -66,12 +69,61 @@
           stone.LoadSeriesInViewport(that.canvasId, seriesInstanceUid);
         });
       }
-      else if (newVal.type == stone.ThumbnailType.PDF ||
-               newVal.type == stone.ThumbnailType.VIDEO) {
+      else if (newVal.type == stone.ThumbnailType.PDF) {
+        if (newVal.complete) {
+          /**
+           * Series is complete <=> One already knows about the
+           * SOPInstanceUIDs that are available in this series. As a
+           * consequence,
+           * "OrthancStone::SeriesMetadataLoader::Accessor" will not
+           * be empty in "ResourcesLoader::FetchPdf()" in C++ code.
+           **/
+          stone.FetchPdf(studyInstanceUid, seriesInstanceUid);
+        } else {
+          /**
+           * The SOPInstanceUIDs in this series are not known
+           * yet. Schedule an "stone.FetchPdf()" one the series
+           * metadata is available.
+           **/
+          pendingSeriesPdf_[seriesInstanceUid] = true;
+        }
+      }
+      else if (newVal.type == stone.ThumbnailType.VIDEO) {
         // TODO
+        console.warn('Videos are not supported yet by the Stone Web viewer');
       }
     }
   },
+  mounted: function() {
+    var that = this;
+    
+    window.addEventListener('PdfLoaded', function(args) {
+      var studyInstanceUid = args.detail.studyInstanceUid;
+      var seriesInstanceUid = args.detail.seriesInstanceUid;
+      var pdfPointer = args.detail.pdfPointer;
+      var pdfSize = args.detail.pdfSize;
+
+      if ('tags' in that.series &&
+          that.series.tags[STUDY_INSTANCE_UID] == studyInstanceUid &&
+          that.series.tags[SERIES_INSTANCE_UID] == seriesInstanceUid) {
+
+        that.status = 'pdf';
+        var pdf = new Uint8Array(HEAPU8.subarray(pdfPointer, pdfPointer + pdfSize));
+
+        /**
+         * It is not possible to bind an "Uint8Array" to a "props"
+         * in the "pdf-viewer" component. So we have to directly
+         * call the method of a component. But, "$refs are only
+         * populated after the component has been rendered", so we
+         * wait for the next rendering.
+         * https://vuejs.org/v2/guide/components-edge-cases.html#Accessing-Child-Component-Instances-amp-Child-Elements
+         **/
+        Vue.nextTick(function() {
+          that.$refs.pdfViewer.LoadPdf(pdf);
+        });
+      }
+    });
+  },
   methods: {
     SeriesDragAccept: function(event) {
       event.preventDefault();
@@ -306,7 +358,6 @@
     SetViewportSeriesInstanceUid: function(viewportIndex, seriesInstanceUid) {
       if (seriesInstanceUid in this.seriesIndex) {
         this.SetViewportSeries(viewportIndex, this.seriesIndex[seriesInstanceUid]);
-
       }
     },
     
@@ -432,12 +483,21 @@
       }
     },
 
-    UpdateIsSeriesComplete: function(seriesInstanceUid) {
+    UpdateIsSeriesComplete: function(studyInstanceUid, seriesInstanceUid) {
       if (seriesInstanceUid in this.seriesIndex) {
         var index = this.seriesIndex[seriesInstanceUid];
         var series = this.series[index];
 
+        var oldComplete = series.complete;
+        
         series.complete = stone.IsSeriesComplete(seriesInstanceUid);
+        
+        if (!oldComplete &&
+            series.complete &&
+            seriesInstanceUid in pendingSeriesPdf_) {
+          stone.FetchPdf(studyInstanceUid, seriesInstanceUid);
+          delete pendingSeriesPdf_[seriesInstanceUid];
+        }
 
         // https://fr.vuejs.org/2016/02/06/common-gotchas/#Why-isn%E2%80%99t-the-DOM-updating
         this.$set(this.series, index, series);
@@ -589,9 +649,10 @@
   app.SetResources(studies, series);
 
   for (var i = 0; i < app.series.length; i++) {
+    var studyInstanceUid = app.series[i].tags[STUDY_INSTANCE_UID];
     var seriesInstanceUid = app.series[i].tags[SERIES_INSTANCE_UID];
     app.UpdateSeriesThumbnail(seriesInstanceUid);
-    app.UpdateIsSeriesComplete(seriesInstanceUid);
+    app.UpdateIsSeriesComplete(studyInstanceUid, seriesInstanceUid);
   }
 });
 
@@ -604,9 +665,9 @@
 
 
 window.addEventListener('MetadataLoaded', function(args) {
-  //var studyInstanceUid = args.detail.studyInstanceUid;
+  var studyInstanceUid = args.detail.studyInstanceUid;
   var seriesInstanceUid = args.detail.seriesInstanceUid;
-  app.UpdateIsSeriesComplete(seriesInstanceUid);
+  app.UpdateIsSeriesComplete(studyInstanceUid, seriesInstanceUid);
 });
 
 
--- a/Applications/StoneWebViewer/WebApplication/index.html	Tue Nov 17 11:18:53 2020 +0100
+++ b/Applications/StoneWebViewer/WebApplication/index.html	Wed Nov 18 11:19:09 2020 +0100
@@ -200,10 +200,10 @@
                             </div>
 
                             <i v-if="series[seriesIndex].type == stone.ThumbnailType.PDF"
-                               class="wvSerieslist__placeholderIcon fa fa-file-text"></i>
+                               class="wvSerieslist__placeholderIcon fa fa-file-pdf"></i>
 
                             <i v-if="series[seriesIndex].type == stone.ThumbnailType.VIDEO"
-                               class="wvSerieslist__placeholderIcon fa fa-video-camera"></i>
+                               class="wvSerieslist__placeholderIcon fa fa-video-video"></i>
 
                             
                             <div v-if="[stone.ThumbnailType.IMAGE, stone.ThumbnailType.NO_PREVIEW].includes(series[seriesIndex].type)"
@@ -524,7 +524,8 @@
     
 
     <script type="text/x-template" id="viewport-template">
-      <div v-bind:style="{ padding:'2px', 
+      <div v-bind:id="canvasId + '-container'"
+           v-bind:style="{ padding:'2px', 
                          position:'absolute', 
                          left: left, 
                          top: top,
@@ -546,7 +547,7 @@
             <div v-show="status == 'ready'"
                  style="position:absolute; left:0; top:0; width:100%; height:100%;">
               <!--div style="width: 100%; height: 100%; background-color: red"></div-->
-              <canvas v-bind:id="canvasId"
+              <canvas v-bind:id="canvasId" class="viewport-canvas"
                       style="position:absolute; left:0; top:0; width:100%; height:100%"
                       oncontextmenu="return false"></canvas>
 
@@ -587,8 +588,9 @@
               [ drop a series here ]
             </div>
 
-            <div v-if="status == 'pdf'" >
-              <pdf-viewer v-bind:prefix="canvasId + '-pdf'"></pdf-viewer>
+            <!-- Don't use "v-if" here, otherwise the tooltips of the PDF viewer are not initialized -->
+            <div v-show="status == 'pdf'" >
+              <pdf-viewer v-bind:prefix="canvasId + '-pdf'" ref="pdfViewer"></pdf-viewer>
             </div>
                 
             <!--div v-if="status == 'video'" class="wvPaneOverlay">
@@ -622,15 +624,29 @@
 
         <div class="wv-overlay">
           <div class="wv-overlay-bottomleft wvPrintExclude">
-            <button class="btn btn-primary" @click="FitWidth()"><i class="fas fa-text-width"></i></button>
-            <button class="btn btn-primary" @click="FitHeight()"><i class="fas fa-text-height"></i></button>
-            <button class="btn btn-primary" @click="ZoomIn()"><i class="fas fa-search-plus"></i></button>
-            <button class="btn btn-primary" @click="ZoomOut()"><i class="fas fa-search-minus"></i></button>
-            <button class="btn btn-primary" @click="PreviousPage()">
+            <button class="btn btn-primary" @click="FitWidth()"
+                    data-toggle="tooltip" data-title="Fit page width">
+              <i class="fas fa-text-width"></i>
+            </button>
+            <button class="btn btn-primary" @click="FitHeight()"
+                    data-toggle="tooltip" data-title="Fit page height">
+              <i class="fas fa-text-height"></i>
+            </button>
+            <button class="btn btn-primary" @click="ZoomIn()"
+                    data-toggle="tooltip" data-title="Zoom in">
+              <i class="fas fa-search-plus"></i>
+            </button>
+            <button class="btn btn-primary" @click="ZoomOut()"
+                    data-toggle="tooltip" data-title="Zoom out">
+              <i class="fas fa-search-minus"></i>
+            </button>
+            <button class="btn btn-primary" @click="PreviousPage()"
+                    data-toggle="tooltip" data-title="Show previous page">
               <i class="fa fa-chevron-circle-left"></i>
             </button>
             &nbsp;&nbsp;{{currentPage}} / {{countPages}}&nbsp;&nbsp;
-            <button class="btn btn-primary" @click="NextPage()">
+            <button class="btn btn-primary" @click="NextPage()"
+                    data-toggle="tooltip" data-title="Show next page">
               <i class="fa fa-chevron-circle-right"></i>
             </button>
           </div>
--- a/Applications/StoneWebViewer/WebApplication/pdf-viewer.js	Tue Nov 17 11:18:53 2020 +0100
+++ b/Applications/StoneWebViewer/WebApplication/pdf-viewer.js	Wed Nov 18 11:19:09 2020 +0100
@@ -20,6 +20,30 @@
 
 
 
+/**
+ * This source file is an adaptation for Vue.js of the sample code
+ * "Previous/Next example" of PDF.js:
+ * https://mozilla.github.io/pdf.js/examples/
+ *
+ * =======================================================================
+ *
+ * Original license of the sample code:
+ *
+ * Copyright 2014 Mozilla Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ **/
+
 
 // Loaded via <script> tag, create shortcut to access PDF.js exports.
 var pdfjsLib = window['pdfjs-dist/build/pdf'];
@@ -30,16 +54,17 @@
 
 
 var ZOOM_FACTOR = 1.3;
-var FIT_MARGIN = 10;
+var FIT_MARGIN = 10;    // Additional margin for width/height fitting, in order to avoid spurious scrollbars
 
 Vue.component('pdf-viewer', {
-  props: [ 'prefix', 'pdf' ],  // "pdf" must correspond to a "Uint8Array"
+  props: [ 'prefix' ],
   template: '#pdf-viewer',
   data: function() {
     return {
       container: null,
       canvas: null,
       ctx: null,
+      pdf: null, // "pdf" must correspond to a "Uint8Array"
       
       scale: 1,
       countPages: 0,
@@ -49,11 +74,6 @@
       pageNumPending: null
     }
   },
-  watch: {
-    pdf: function(newVal, oldVal) {
-      this.LoadPdf();
-    }
-  },
   mounted: function() {
     this.container = document.getElementById(this.prefix + '-container');
     this.canvas = document.getElementById(this.prefix + '-canvas');
@@ -106,7 +126,6 @@
           // https://github.com/mozilla/pdf.js/issues/5628
           var scrollbarHeight = window.innerHeight - document.body.clientHeight + FIT_MARGIN;
           that.scale = (that.container.offsetHeight - scrollbarHeight) / page.getViewport({ scale: 1.0 }).height;
-          //that.scale = that.container.clientHeight / page.getViewport({ scale: 1.0 }).height;
           that.QueueRenderPage(that.currentPage);
         });
       }
@@ -120,18 +139,24 @@
       this.QueueRenderPage(this.currentPage);  
     },
     LoadPdf: function(pdf) {
-      var that = this;
-      pdfjsLib.getDocument(new Uint8Array(this.pdf)).promise.then(function(pdfDoc_) {
-        that.pdfDoc = pdfDoc_;
-        that.currentPage = 0;
-        that.countPages = pdfDoc_.numPages;
-        that.scale = 1;
-        that.isRendering = false;
-        that.pageNumPending = null;
-        
-        // Initial/first page rendering
-        that.RenderPage(1);
-      });
+      if (!this.isRendering &&
+          pdf.length > 0) {
+        this.pdf = pdf;
+        this.isRendering = true;
+
+        var that = this;
+        pdfjsLib.getDocument(this.pdf).promise.then(function(pdfDoc_) {
+          that.pdfDoc = pdfDoc_;
+          that.currentPage = 1;
+          that.countPages = pdfDoc_.numPages;
+          that.scale = 1;
+          that.isRendering = false;
+          that.pageNumPending = null;
+          
+          // Initial/first page rendering, after fitting the PDF to the available viewport
+          that.FitHeight();
+        });
+      }
     },
     RenderPage: function(pageNum) {
       var that = this;
@@ -187,11 +212,25 @@
       if (event.ctrlKey) {
         if (event.deltaY < 0) {
           this.ZoomIn();
+          event.preventDefault();
         } else if (event.deltaY > 0) {
           this.ZoomOut();
+          event.preventDefault();
         }
-        
-        event.preventDefault();
+      } else if (!event.shiftKey &&
+                 !event.altKey &&
+                 !event.metaKey) {
+        // Is the vertical scrollbar hidden?
+        // https://stackoverflow.com/a/4814526/881731
+        if (this.container.scrollHeight <= this.container.clientHeight) {
+          if (event.deltaY < 0) {
+            this.PreviousPage();
+            event.preventDefault();
+          } else if (event.deltaY > 0) {
+            this.NextPage();
+            event.preventDefault();
+          }
+        }
       }
     }
   }
--- a/Applications/StoneWebViewer/WebApplication/print.js	Tue Nov 17 11:18:53 2020 +0100
+++ b/Applications/StoneWebViewer/WebApplication/print.js	Wed Nov 18 11:19:09 2020 +0100
@@ -36,7 +36,7 @@
   // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html
   var realToCSSPixels = window.devicePixelRatio;
 
-  $('#viewport canvas').each(function(key, canvas) {
+  $('.viewport-canvas').each(function(key, canvas) {
     if ($(canvas).is(':visible')) {
       $(canvas).width(Math.floor(realToCSSPixels * $(canvas).get(0).clientWidth));
       $(canvas).height(Math.floor(realToCSSPixels * $(canvas).get(0).clientHeight));
@@ -52,8 +52,8 @@
   body.removeClass('print');
   body.css('width', '100%');
   body.css('height', '100%');
-  $('#viewport canvas').css('width', '100%');
-  $('#viewport canvas').css('height', '100%');
+  $('.viewport-canvas').css('width', '100%');
+  $('.viewport-canvas').css('height', '100%');
   
   stone.FitForPrint();
 }
--- a/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Tue Nov 17 11:18:53 2020 +0100
+++ b/Applications/StoneWebViewer/WebAssembly/StoneWebViewer.cpp	Wed Nov 18 11:19:09 2020 +0100
@@ -184,9 +184,14 @@
 
     virtual void SignalSeriesMetadataLoaded(const std::string& studyInstanceUid,
                                             const std::string& seriesInstanceUid) = 0;
+
+    virtual void SignalSeriesPdfLoaded(const std::string& studyInstanceUid,
+                                       const std::string& seriesInstanceUid,
+                                       const std::string& pdf) = 0;
   };
   
 private:
+  OrthancStone::ILoadersContext&                           context_;
   std::unique_ptr<IObserver>                               observer_;
   OrthancStone::DicomSource                                source_;
   size_t                                                   pending_;
@@ -196,7 +201,9 @@
   boost::shared_ptr<OrthancStone::SeriesThumbnailsLoader>  thumbnailsLoader_;
   boost::shared_ptr<OrthancStone::SeriesMetadataLoader>    metadataLoader_;
 
-  explicit ResourcesLoader(const OrthancStone::DicomSource& source) :
+  explicit ResourcesLoader(OrthancStone::ILoadersContext& context,
+                           const OrthancStone::DicomSource& source) :
+    context_(context),
     source_(source),
     pending_(0),
     studies_(new OrthancStone::LoadedDicomResources(Orthanc::DICOM_TAG_STUDY_INSTANCE_UID)),
@@ -292,11 +299,56 @@
     pending_ += 2;
   }
 
+
+  class PdfInfo : public Orthanc::IDynamicObject
+  {
+  private:
+    std::string  studyInstanceUid_;
+    std::string  seriesInstanceUid_;
+
+  public:
+    PdfInfo(const std::string& studyInstanceUid,
+            const std::string& seriesInstanceUid) :
+      studyInstanceUid_(studyInstanceUid),
+      seriesInstanceUid_(seriesInstanceUid)
+    {
+    }
+
+    const std::string& GetStudyInstanceUid() const
+    {
+      return studyInstanceUid_;
+    }
+
+    const std::string& GetSeriesInstanceUid() const
+    {
+      return seriesInstanceUid_;
+    }
+  };
+
+
+  void Handle(const OrthancStone::ParseDicomSuccessMessage& message)
+  {
+    const PdfInfo& info = dynamic_cast<const PdfInfo&>(message.GetOrigin().GetPayload());
+
+    if (observer_.get() != NULL)
+    {
+      std::string pdf;
+      if (message.GetDicom().ExtractPdf(pdf))
+      {
+        observer_->SignalSeriesPdfLoaded(info.GetStudyInstanceUid(), info.GetSeriesInstanceUid(), pdf);
+      }
+      else
+      {
+        LOG(ERROR) << "Unable to extract PDF from series: " << info.GetSeriesInstanceUid();
+      }
+    }
+  }
+
 public:
   static boost::shared_ptr<ResourcesLoader> Create(OrthancStone::ILoadersContext::ILock& lock,
                                                    const OrthancStone::DicomSource& source)
   {
-    boost::shared_ptr<ResourcesLoader> loader(new ResourcesLoader(source));
+    boost::shared_ptr<ResourcesLoader> loader(new ResourcesLoader(lock.GetContext(), source));
 
     loader->resourcesLoader_ = OrthancStone::DicomResourcesLoader::Create(lock);
     loader->thumbnailsLoader_ = OrthancStone::SeriesThumbnailsLoader::Create(lock, PRIORITY_LOW);
@@ -310,6 +362,9 @@
 
     loader->Register<OrthancStone::SeriesMetadataLoader::SuccessMessage>(
       *loader->metadataLoader_, &ResourcesLoader::Handle);
+
+    loader->Register<OrthancStone::ParseDicomSuccessMessage>(
+      lock.GetOracleObservable(), &ResourcesLoader::Handle);
     
     return loader;
   }
@@ -408,6 +463,41 @@
   {  
     observer_.reset(observer);
   }
+
+  void FetchPdf(const std::string& studyInstanceUid,
+                const std::string& seriesInstanceUid)
+  {
+    OrthancStone::SeriesMetadataLoader::Accessor accessor(*metadataLoader_, seriesInstanceUid);
+    
+    if (accessor.IsComplete())
+    {
+      if (accessor.GetInstancesCount() > 1)
+      {
+        LOG(INFO) << "Series with more than one instance, will show the first PDF: "
+                  << seriesInstanceUid;
+      }
+
+      for (size_t i = 0; i < accessor.GetInstancesCount(); i++)
+      {
+        std::string sopClassUid, sopInstanceUid;
+        if (accessor.GetInstance(i).LookupStringValue(sopClassUid, Orthanc::DICOM_TAG_SOP_CLASS_UID, false) &&
+            accessor.GetInstance(i).LookupStringValue(sopInstanceUid, Orthanc::DICOM_TAG_SOP_INSTANCE_UID, false) &&
+            sopClassUid == "1.2.840.10008.5.1.4.1.1.104.1")
+        {
+          std::unique_ptr<OrthancStone::ILoadersContext::ILock> lock(context_.Lock());
+          lock->Schedule(
+            GetSharedObserver(), PRIORITY_NORMAL, OrthancStone::ParseDicomFromWadoCommand::Create(
+              source_, studyInstanceUid, seriesInstanceUid, sopInstanceUid,
+              false /* no transcoding */, Orthanc::DicomTransferSyntax_LittleEndianExplicit /* dummy value */,
+              new PdfInfo(studyInstanceUid, seriesInstanceUid)));
+          
+          return;
+        }
+      }
+
+      LOG(WARNING) << "Series without a PDF: " << seriesInstanceUid;
+    }
+  }
 };
 
 
@@ -2229,7 +2319,6 @@
       static_cast<int>(countFrames),
       quality);
 
-
     UpdateReferenceLines();
   }
 
@@ -2241,6 +2330,25 @@
       it->second->FocusOnPoint(click);
     }
   }
+
+  virtual void SignalSeriesPdfLoaded(const std::string& studyInstanceUid,
+                                     const std::string& seriesInstanceUid,
+                                     const std::string& pdf) ORTHANC_OVERRIDE
+  {
+    EM_ASM({
+        const customEvent = document.createEvent("CustomEvent");
+        customEvent.initCustomEvent("PdfLoaded", false, false,
+                                    { "studyInstanceUid" : UTF8ToString($0),
+                                        "seriesInstanceUid" : UTF8ToString($1),
+                                        "pdfPointer" : $2,
+                                        "pdfSize": $3});
+        window.dispatchEvent(customEvent);
+      },
+      studyInstanceUid.c_str(),
+      seriesInstanceUid.c_str(),
+      pdf.empty() ? 0 : reinterpret_cast<intptr_t>(pdf.c_str()),  // Explicit conversion to an integer
+      pdf.size());
+  }
 };
 
 
@@ -2746,4 +2854,17 @@
     }
     EXTERN_CATCH_EXCEPTIONS;
   }
+
+
+  EMSCRIPTEN_KEEPALIVE
+  void FetchPdf(const char* studyInstanceUid,
+                const char* seriesInstanceUid)
+  {
+    try
+    {
+      LOG(INFO) << "Fetching PDF series: " << seriesInstanceUid;
+      GetResourcesLoader().FetchPdf(studyInstanceUid, seriesInstanceUid);
+    }
+    EXTERN_CATCH_EXCEPTIONS;
+  }
 }
--- a/OrthancStone/Sources/Loaders/SeriesThumbnailsLoader.cpp	Tue Nov 17 11:18:53 2020 +0100
+++ b/OrthancStone/Sources/Loaders/SeriesThumbnailsLoader.cpp	Wed Nov 18 11:19:09 2020 +0100
@@ -262,7 +262,7 @@
             type = ExtractSopClassUid(sopClassUid);
           }
         }
-      
+
         GetLoader()->AcquireThumbnail(GetSource(), GetStudyInstanceUid(),
                                       GetSeriesInstanceUid(), new Thumbnail(type));
       }
@@ -302,7 +302,7 @@
     {
       // The DICOMweb wasn't able to generate a thumbnail, try to
       // retrieve the SopClassUID tag using QIDO-RS
-
+      
       std::map<std::string, std::string> arguments, headers;
       arguments["0020000D"] = GetStudyInstanceUid();
       arguments["0020000E"] = GetSeriesInstanceUid();