/* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab filetype=javascript : */ /** * @license * Core UI functions for pdf2htmlEX * Copyright 2012,2013 Lu Wang and other contributors * https://github.com/coolwanglu/pdf2htmlEX/blob/master/share/LICENSE */ /* * pdf2htmlEX.js : a simple UI for pdf2htmlEX * * Copyright 2012,2013 Lu Wang and other contributors */ /* * Dependencies: * jQuery */ /* * Attention: * This files is to be optimized by closure-compiler, * so pay attention to the forms of property names: * * string/bracket form is safe, won't be optimized: * var obj={ 'a':'b' }; obj['a'] = 'b'; * name/dot form will be optimized, the name is likely to be modified: * var obj={ a:'b' }; obj.a = 'b'; * * Either form can be used for internal objects, * but must be consistent for each one respectively. * * string/bracket form must be used for external objects * e.g. DEFAULT_CONFIG, object stored in page-data * property names are part of the `protocol` in these cases. * */ 'use strict'; /* The namespace */ (function(pdf2htmlEX){ /** * @const * @struct */ var CSS_CLASS_NAMES = { page_frame : '@CSS_PAGE_FRAME_CN@', page_content_box : '@CSS_PAGE_CONTENT_BOX_CN@', page_data : '@CSS_PAGE_DATA_CN@', background_image : '@CSS_BACKGROUND_IMAGE_CN@', link : '@CSS_LINK_CN@', __dummy__ : 'no comma' }; /** * @const * @dict */ var DEFAULT_CONFIG = { // id of the element to put the pages in 'container_id' : 'page-container', // id of the element for sidebar (to open and close) 'sidebar_id' : 'sidebar', // id of the element for outline 'outline_id' : 'outline', // class for the loading indicator 'loading_indicator_cls' : 'loading-indicator', // How many page shall we preload that are below the last visible page 'preload_pages' : 3, // how many ms should we wait before actually rendering the pages and after a scroll event 'render_timeout' : 100, // zoom ratio step for each zoom in/out event 'scale_step' : 0.9, // register global key handler 'register_key_handler' : true, '__dummy__' : 'no comma' }; /** @const */ var EPS = 1e-6; function invert(ctm) { var det = ctm[0] * ctm[3] - ctm[1] * ctm[2]; return [ ctm[3] / det ,-ctm[1] / det ,-ctm[2] / det ,ctm[0] / det ,(ctm[2] * ctm[5] - ctm[3] * ctm[4]) / det ,(ctm[1] * ctm[4] - ctm[0] * ctm[5]) / det ]; }; function transform(ctm, pos) { return [ctm[0] * pos[0] + ctm[2] * pos[1] + ctm[4] ,ctm[1] * pos[0] + ctm[3] * pos[1] + ctm[5]]; }; /** * @constructor * @struct */ function Page(page, container) { if(!page) return; this.loaded = false; this.shown = false; this.$p = $(page); // page frame element this.$container = $(container); this.n = parseInt(this.$p.data('page-no'), 16); // content box this.$b = $('.'+CSS_CLASS_NAMES.page_content_box, this.$p); // page size // Need to make rescale work when page_content_box is not loaded, yet this.h = this.$p.height(); this.w = this.$p.width(); // if page is loaded if (this.$b.length > 0) { /* * scale ratios * * default_r : the first one * set_r : last set * cur_r : currently using */ this.default_r = this.set_r = this.cur_r = this.h / this.$b.height(); this.page_data = $($('.'+CSS_CLASS_NAMES.page_data, this.$p)[0]).data('data'); this.ctm = this.page_data['ctm']; this.ictm = invert(this.ctm); this.loaded = true; } }; $.extend(Page.prototype, /** @lends {Page.prototype} */ { /* hide & show are for contents, the page frame is still there */ hide : function(){ this.$b.removeClass('opened'); this.shown = false; }, show : function(){ if (this.loaded) { if (! this.shown) { this.$b.addClass('opened'); this.shown = true; } } }, rescale : function(ratio) { if(ratio == 0) { this.set_r = this.default_r; } else { this.set_r = ratio; } if(Math.abs(this.set_r - this.cur_r) > EPS) { this.cur_r = this.set_r; this.$b.css('transform', 'scale('+this.cur_r.toFixed(3)+')'); } this.$p.height(this.h * this.set_r); this.$p.width(this.w * this.set_r); }, /* return if any part of this page is shown in the container */ is_visible : function() { var off = this.position(); return !((off[1] > this.h) || (off[1] + this.$container.height() < 0)); }, /* return if this page or any neighbor of it is visible */ is_nearly_visible : function() { var off = this.position(); /* * I should use the height of the previous page or the next page here * but since they are not easily available, just use '*2', which should be a good estimate in most cases */ return !((off[1] > this.h * 2) || (off[1] + this.$container.height() * 2 < 0)); }, /* * return the coordinate of the top-left corner of container * in our coordinate system */ position : function () { var rect = this.$p[0].getBoundingClientRect(); // getBoundingClientRect will be called on $container // for a lot of times(= #pages), just hope JIT works var rect_c = this.$container[0].getBoundingClientRect(); return [rect_c.left-rect.left, rect_c.top-rect.top]; /* var off = this.$p.offset(); var off_c = this.$container.offset(); return [off_c.left-off.left, off_c.top-off.top]; */ } }); /** * export pdf2htmlEX.Viewer * @constructor */ function Viewer(config) { /* do nothing if jQuery is not ready */ if(!jQuery) return; this.config = $.extend({}, DEFAULT_CONFIG, config); this.pages_loading = []; this.init_before_loading_content(); var _ = this; $(function(){_.init_after_loading_content();}); }; $.extend(Viewer.prototype, /** @lends {Viewer.prototype} */ { scale : 1, init_before_loading_content : function() { /*hide all pages before loading, will reveal only visible ones later */ this.pre_hide_pages(); }, init_after_loading_content : function() { this.$sidebar = $('#'+this.config['sidebar_id']); this.$outline = $('#'+this.config['outline_id']); this.$container = $('#'+this.config['container_id']); this.$loading_indicator = $('.'+this.config['loading_indicator_cls']); // Open the outline if nonempty if(this.$outline.children().length > 0) { this.$sidebar.addClass('opened'); } this.find_pages(); // register schedule rendering var _ = this; this.$container.scroll(function(){ _.schedule_render(); }); if(this.config['register_key_handler']) this.register_key_handler(); // handle links this.$container.add(this.$outline).on('click', '.'+CSS_CLASS_NAMES.link, this, this.link_handler); this.render(); }, /* * set up this.pages and this.page_map * pages is an array holding all the Page objects * page-Map maps an original page number (in PDF) to the corresponding index in page */ find_pages : function() { var new_pages = []; var new_page_map = {}; var $pl= this.$container.find('.'+CSS_CLASS_NAMES.page_frame); /* don't use for(..in..) since $pl is more than an Array */ for(var i = 0, l = $pl.length; i < l; ++i) { var p = new Page($pl[i], this.$container); new_page_map[p.n] = i; new_pages.push(p); } this.pages = new_pages; this.page_map = new_page_map; }, load_page : function(idx, pages_to_preload, successCallback, errorCallback) { var pages = this.pages; if (idx >= pages.length) return; // Page does not exist var cur_page = pages[idx]; if (cur_page.loaded) return; // Page is loaded if (this.pages_loading[idx]) return; // Page is already loading var page_no_hex = cur_page.n.toString(16); var $pf = cur_page.$p; this.$loading_indicator.clone().show().appendTo($pf); var url = $pf.data('page-url'); if (url) { this.pages_loading[idx] = true; // Set semaphore /* closure variables */ var _ = this; var _idx = idx; $.ajax({ url: url, dataType: 'text' }).done(function(data){ // replace the old page with loaded data // the loading indicator on this page should also be destroyed var p = _.pages[idx]; p.$p.replaceWith(data); var $new_pf = _.$container.find('#' + CSS_CLASS_NAMES.page_frame + p.n.toString(16)); p = new Page($new_pf, _.$container); p.hide(); p.rescale(_.scale); // disable background image dragging $new_pf.find('.'+CSS_CLASS_NAMES.background_image).on('dragstart', function(e){return false;}); _.pages[idx] = p; _.schedule_render(); // Reset loading token delete _.pages_loading[idx]; if (successCallback) successCallback(_.pages[idx]); }).fail(function(jqXHR, textStatus, errorThrown){ // Reset loading token delete _.pages_loading[idx]; if (errorCallback) errorCallback(); }); } // Concurrent prefetch of the next pages if (pages_to_preload === undefined) pages_to_preload = this.config['preload_pages']; if (--pages_to_preload > 0) this.load_page(idx+1, pages_to_preload); }, pre_hide_pages : function() { /* pages might have not been loaded yet, so add a CSS rule */ var s = '@media screen{.'+CSS_CLASS_NAMES.page_content_box+'{display:none;}}'; var n = document.createElement('style'); n.type = 'text/css'; if (n.styleSheet) { n.styleSheet.cssText = s; } else { n.appendChild(document.createTextNode(s)); } document.getElementsByTagName('head')[0].appendChild(n); }, render : function () { /* hide (positional) invisible pages */ var pl = this.pages; var pm = this.page_map; for(var i = 0, l = pl.length; i < l; ++i) { var p = pl[i]; if(p.is_nearly_visible()){ if (p.loaded) { p.show(); } else this.load_page(pm[p.n]); } else { p.hide(); } } }, schedule_render : function() { if(this.render_timer) clearTimeout(this.render_timer); var _ = this; this.render_timer = setTimeout(function () { _.render(); }, this.config['render_timeout']); }, /* * Handling key events, zooming, scrolling etc. */ register_key_handler: function () { /* * When user try to zoom in/out using ctrl + +/- or mouse wheel * handle this and prevent the default behaviours * * Code credit to PDF.js */ var _ = this; // Firefox specific event, so that we can prevent browser from zooming $(window).on('DOMMouseScroll', function(e) { if (e.ctrlKey) { e.preventDefault(); _.rescale(Math.pow(_.config['scale_step'], e.detail), true); } }); $(window).on('keydown', function keydown(e) { var handled = false; /* var cmd = (e.ctrlKey ? 1 : 0) | (e.altKey ? 2 : 0) | (e.shiftKey ? 4 : 0) | (e.metaKey ? 8 : 0) ; */ var with_ctrl = e.ctrlKey || e.metaKey; var with_alt = e.altKey; switch (e.keyCode) { case 61: // FF/Mac '=' case 107: // FF '+' and '=' case 187: // Chrome '+' if(with_ctrl){ _.rescale(1.0 / _.config['scale_step'], true); handled = true; } break; case 173: // FF/Mac '-' case 109: // FF '-' case 189: // Chrome '-' if(with_ctrl){ _.rescale(_.config['scale_step'], true); handled = true; } break; case 48: // '0' if(with_ctrl){ _.rescale(0, false); handled = true; } break; case 33: // Page UP: if (with_alt) { // alt-pageup -> scroll one page up _.scroll_to(_.get_prev_page()); } else { // pageup -> scroll one screen up _.$container.scrollTop(_.$container.scrollTop()-_.$container.height()); } handled = true; break; case 34: // Page DOWN if (with_alt) { // alt-pagedown -> scroll one page down _.scroll_to(_.get_next_page()); } else { // pagedown -> scroll one screen down _.$container.scrollTop(_.$container.scrollTop()+_.$container.height()); } handled = true; break; case 35: // End if (with_ctrl) { _.$container.scrollTop(_.$container[0].scrollHeight); handled = true; } break; case 36: // Home if (e.with_ctrl) { _.$container.scrollTop(0); handled = true; } break; } if(handled) { e.preventDefault(); return; } }); }, // TODO get_next_page : function() { return undefined; }, get_prev_page : function() { return undefined; }, // TODO: offsetX/Y is by default the center of container rescale : function (ratio, is_relative, offsetX, offsetY) { if (! offsetX) offsetX = 0; if (! offsetY) offsetY = 0; // Save offset of the active page var active_page = this.get_active_page(); if(!active_page) return; var prev_offset = active_page.$p[0].getBoundingClientRect(); var old_scale = this.scale; var pl = this.pages; // Set new scale if (ratio == 0) { this.scale = 1; is_relative = false; } else if (is_relative) this.scale *= ratio; else this.scale = ratio; // Rescale pages for(var i = 0, l = pl.length; i < l; ++i) pl[i].rescale(this.scale); // Correct container scroll to keep view aligned while zooming var correction_top = active_page.$p[0].getBoundingClientRect().top - prev_offset.top; this.$container.scrollTop( this.$container.scrollTop() + correction_top + offsetY ); // Take the center of the view as a reference var prev_center_x = this.$container.width() / 2 - prev_offset.left; // Calculate the difference respect the center of the view after the zooming var correction_left = prev_center_x * (this.scale/old_scale - 1) + active_page.$p.offset().left - prev_offset.left; // Scroll the container accordingly to keep alignment to the initial reference this.$container.scrollLeft( this.$container.scrollLeft() + correction_left + offsetX ); // some pages' visibility may be toggled, wait for next render() this.schedule_render(); }, fit_width : function () { var active_page = this.get_active_page(); if(!active_page) return; this.rescale(this.$container.width() / active_page.w, false); this.scroll_to(this.page_map[active_page.n], [0,0]); }, fit_height : function () { var active_page = this.get_active_page(); if(!active_page) return; this.rescale(this.$container.height() / active_page.h, false); this.scroll_to(this.page_map[active_page.n], [0,0]); }, // TODO: preserve active_page idx after rescaling get_active_page : function () { var pl = this.pages; for(var i = 0, l = pl.length; i < l; ++i) { if (pl[i].is_visible()) return pl[i]; } return; }, get_containing_page : function(obj) { /* get the page obj containing obj */ var $p = obj.closest('.'+CSS_CLASS_NAMES.page_frame); if($p.length == 0) return; /* * Get original page number and map it to index of pages * TODO: store the index on the dom element */ var pn = (new Page($p[0], null)).n; var pm = this.page_map; return (pn in pm) && this.pages[pm[pn]]; }, link_handler : function (e) { var _ = e.data; var t = $(e.currentTarget); var cur_pos = [0,0]; // cur_page might be undefined, e.g. from Outline var cur_page = _.get_containing_page(t); if(cur_page) { cur_pos = cur_page.position(); //get the coordinates in default user system cur_pos = transform(cur_page.ictm, [cur_pos[0], cur_page.h-cur_pos[1]]); } var detail_str = /** @type{string} */ (t.attr('data-dest-detail')); if(detail_str === undefined) return; var ok = false; var detail = JSON.parse(detail_str); var target_page_no = detail[0]; var page_map = this.page_map; if(!(target_page_no in page_map)) return; var target_page_idx = page_map[target_page_no]; var target_page = _.pages[target_page_idx]; var pos = [0,0]; var upside_down = true; // TODO: zoom // TODO: BBox switch(detail[1]) { case 'XYZ': pos = [(detail[2] == null) ? cur_pos[0] : detail[2] ,(detail[3] == null) ? cur_pos[1] : detail[3]]; ok = true; break; case 'Fit': case 'FitB': pos = [0,0]; ok = true; break; case 'FitH': case 'FitBH': pos = [0, (detail[2] == null) ? cur_pos[1] : detail[2]]; ok = true; break; case 'FitV': case 'FitBV': pos = [(detail[2] == null) ? cur_pos[0] : detail[2], 0]; ok = true; break; case 'FitR': /* locate the top-left corner of the rectangle */ pos = [detail[2], detail[5]]; upside_down = false; ok = true; break; default: ok = false; break; } if(ok) { /* page should of type Page */ var transform_and_scroll = function(page) { pos = transform(page.ctm, pos); if(upside_down) { pos[1] = page.h - pos[1]; } _.scroll_to(target_page_idx, pos); }; if (target_page.loaded) { transform_and_scroll(target_page); } else { // Scroll to the exact position once loaded. _.load_page(target_page_idx, undefined, transform_and_scroll); // In the meantime page gets loaded, scroll approximately position for maximum responsiveness. _.scroll_to(target_page_idx, [0,0]); } e.preventDefault(); } }, /* pos=[x,y], where (0,0) is the top-left corner */ scroll_to : function(page_idx, pos) { var target_page = this.pages[page_idx]; if(target_page === undefined) return; if(pos === undefined) pos = [0,0]; var cur_target_pos = target_page.position(); this.$container.scrollLeft(this.$container.scrollLeft()-cur_target_pos[0]+pos[0]); this.$container.scrollTop(this.$container.scrollTop()-cur_target_pos[1]+pos[1]); }, __last_member__ : 'no comma' /*,*/ }); pdf2htmlEX['Viewer'] = Viewer; })(window['pdf2htmlEX'] = window['pdf2htmlEX'] || {});