Mercurial > hg > orthanc-stone
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> {{currentPage}} / {{countPages}} - <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();