1 /**
  2  * Copyright (C) 2013 KO GmbH <copyright@kogmbh.com>
  3  *
  4  * @licstart
  5  * This file is part of WebODF.
  6  *
  7  * WebODF is free software: you can redistribute it and/or modify it
  8  * under the terms of the GNU Affero General Public License (GNU AGPL)
  9  * as published by the Free Software Foundation, either version 3 of
 10  * the License, or (at your option) any later version.
 11  *
 12  * WebODF is distributed in the hope that it will be useful, but
 13  * WITHOUT ANY WARRANTY; without even the implied warranty of
 14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 15  * GNU Affero General Public License for more details.
 16  *
 17  * You should have received a copy of the GNU Affero General Public License
 18  * along with WebODF.  If not, see <http://www.gnu.org/licenses/>.
 19  * @licend
 20  *
 21  * @source: http://www.webodf.org/
 22  * @source: https://github.com/kogmbh/WebODF/
 23  */
 24 
 25 /*global runtime, core, gui, odf, ops */
 26 
 27 /**
 28  * @constructor
 29  * @param {!ops.Session} session
 30  * @param {!gui.SessionConstraints} sessionConstraints
 31  * @param {!gui.SessionContext} sessionContext
 32  * @param {!string} inputMemberId
 33  * @param {!odf.ObjectNameGenerator} objectNameGenerator
 34  */
 35 gui.ImageController = function ImageController(
 36     session,
 37     sessionConstraints,
 38     sessionContext,
 39     inputMemberId,
 40     objectNameGenerator
 41     ) {
 42     "use strict";
 43 
 44     var /**@const
 45            @type{!Object.<!string, !string>}*/
 46         fileExtensionByMimetype = {
 47             "image/gif": ".gif",
 48             "image/jpeg": ".jpg",
 49             "image/png": ".png"
 50         },
 51         /**@const
 52            @type{!string}*/
 53         textns = odf.Namespaces.textns,
 54         odtDocument = session.getOdtDocument(),
 55         odfUtils = odf.OdfUtils,
 56         formatting = odtDocument.getFormatting(),
 57         eventNotifier = new core.EventNotifier([
 58             gui.HyperlinkController.enabledChanged
 59         ]),
 60         isEnabled = false;
 61 
 62     /**
 63      * @return {undefined}
 64      */
 65     function updateEnabledState() {
 66         var /**@type{!boolean}*/newIsEnabled = true;
 67 
 68         if (sessionConstraints.getState(gui.CommonConstraints.EDIT.REVIEW_MODE) === true) {
 69             newIsEnabled = /**@type{!boolean}*/(sessionContext.isLocalCursorWithinOwnAnnotation());
 70         }
 71 
 72         if (newIsEnabled !== isEnabled) {
 73             isEnabled = newIsEnabled;
 74             eventNotifier.emit(gui.ImageController.enabledChanged, isEnabled);
 75         }
 76     }
 77 
 78     /**
 79      * @param {!ops.OdtCursor} cursor
 80      * @return {undefined}
 81      */
 82     function onCursorEvent(cursor) {
 83         if (cursor.getMemberId() === inputMemberId) {
 84             updateEnabledState();
 85         }
 86     }
 87 
 88     /**
 89      * @return {!boolean}
 90      */
 91     this.isEnabled = function () {
 92         return isEnabled;
 93     };
 94 
 95     /**
 96      * @param {!string} eventid
 97      * @param {!Function} cb
 98      * @return {undefined}
 99      */
100     this.subscribe = function (eventid, cb) {
101         eventNotifier.subscribe(eventid, cb);
102     };
103 
104     /**
105      * @param {!string} eventid
106      * @param {!Function} cb
107      * @return {undefined}
108      */
109     this.unsubscribe = function (eventid, cb) {
110         eventNotifier.unsubscribe(eventid, cb);
111     };
112 
113 
114     /**
115      * @param {!string} name
116      * @return {!ops.Operation}
117      */
118     function createAddGraphicsStyleOp(name) {
119         var op = new ops.OpAddStyle();
120         op.init({
121             memberid: inputMemberId,
122             styleName: name,
123             styleFamily: 'graphic',
124             isAutomaticStyle: false,
125             setProperties: {
126                 "style:graphic-properties": {
127                     "text:anchor-type": "paragraph",
128                     "svg:x": "0cm",
129                     "svg:y": "0cm",
130                     "style:wrap": "dynamic",
131                     "style:number-wrapped-paragraphs": "no-limit",
132                     "style:wrap-contour": "false",
133                     "style:vertical-pos": "top",
134                     "style:vertical-rel": "paragraph",
135                     "style:horizontal-pos": "center",
136                     "style:horizontal-rel": "paragraph"
137                 }
138             }
139         });
140         return op;
141     }
142 
143     /**
144      * @param {!string} styleName
145      * @param {!string} parentStyleName
146      * @return {!ops.Operation}
147      */
148     function createAddFrameStyleOp(styleName, parentStyleName) {
149         var op = new ops.OpAddStyle();
150         op.init({
151             memberid: inputMemberId,
152             styleName: styleName,
153             styleFamily: 'graphic',
154             isAutomaticStyle: true,
155             setProperties: {
156                 "style:parent-style-name": parentStyleName,
157                 // a list of properties would be generated by default when inserting a image in LO.
158                 // They have no UI impacts in webodf, but copied here in case LO requires them to display image correctly.
159                 "style:graphic-properties": {
160                     "style:vertical-pos": "top",
161                     "style:vertical-rel": "baseline",
162                     "style:horizontal-pos": "center",
163                     "style:horizontal-rel": "paragraph",
164                     "fo:background-color": "transparent",
165                     "style:background-transparency": "100%",
166                     "style:shadow": "none",
167                     "style:mirror": "none",
168                     "fo:clip": "rect(0cm, 0cm, 0cm, 0cm)",
169                     "draw:luminance": "0%",
170                     "draw:contrast": "0%",
171                     "draw:red": "0%",
172                     "draw:green": "0%",
173                     "draw:blue": "0%",
174                     "draw:gamma": "100%",
175                     "draw:color-inversion": "false",
176                     "draw:image-opacity": "100%",
177                     "draw:color-mode": "standard"
178                 }
179             }
180         });
181         return op;
182     }
183 
184     /**
185      * @param {!string} mimetype
186      * @return {?string}
187      */
188     function getFileExtension(mimetype) {
189         mimetype = mimetype.toLowerCase();
190         return fileExtensionByMimetype.hasOwnProperty(mimetype) ? fileExtensionByMimetype[mimetype] : null;
191     }
192 
193     /**
194      * @param {!string} mimetype
195      * @param {!string} content base64 encoded string
196      * @param {!string} widthMeasure Width + units of the image
197      * @param {!string} heightMeasure Height + units of the image
198      * @return {undefined}
199      */
200     function insertImageInternal(mimetype, content, widthMeasure, heightMeasure) {
201         var /**@const@type{!string}*/graphicsStyleName = "Graphics",
202             stylesElement = odtDocument.getOdfCanvas().odfContainer().rootElement.styles,
203             fileExtension = getFileExtension(mimetype),
204             fileName,
205             graphicsStyleElement,
206             frameStyleName,
207             op, operations = [];
208 
209         runtime.assert(fileExtension !== null, "Image type is not supported: " + mimetype);
210         fileName = "Pictures/" + objectNameGenerator.generateImageName() + fileExtension;
211 
212         // TODO: eliminate duplicate image
213         op = new ops.OpSetBlob();
214         op.init({
215             memberid: inputMemberId,
216             filename: fileName,
217             mimetype: mimetype,
218             content: content
219         });
220         operations.push(op);
221 
222         // Add the 'Graphics' style if it does not exist in office:styles. It is required by LO to popup the
223         // picture option dialog when double clicking the image
224         // TODO: in collab mode this can result in unsolvable conflict if two add this style at the same time
225         graphicsStyleElement = formatting.getStyleElement(graphicsStyleName, "graphic", [stylesElement]);
226         if (!graphicsStyleElement) {
227             op = createAddGraphicsStyleOp(graphicsStyleName);
228             operations.push(op);
229         }
230 
231         // TODO: reuse an existing graphic style (if there is one) that has same style as default;
232         frameStyleName = objectNameGenerator.generateStyleName();
233         op = createAddFrameStyleOp(frameStyleName, graphicsStyleName);
234         operations.push(op);
235 
236         op = new ops.OpInsertImage();
237         op.init({
238             memberid: inputMemberId,
239             position: odtDocument.getCursorPosition(inputMemberId),
240             filename: fileName,
241             frameWidth: widthMeasure,
242             frameHeight: heightMeasure,
243             frameStyleName: frameStyleName,
244             frameName: objectNameGenerator.generateFrameName()
245         });
246         operations.push(op);
247 
248         session.enqueue(operations);
249     }
250 
251     /**
252      * Scales the supplied image rect to fit within the page content horizontal
253      * and vertical limits, whilst preserving the aspect ratio.
254      *
255      * @param {!{width: number, height: number}} originalSize
256      * @param {!{width: number, height: number}} pageContentSize
257      * @return {!{width: number, height: number}}
258      */
259     function scaleToAvailableContentSize(originalSize, pageContentSize) {
260         var widthRatio = 1,
261             heightRatio = 1,
262             ratio;
263         if (originalSize.width > pageContentSize.width) {
264             widthRatio = pageContentSize.width / originalSize.width;
265         }
266         if (originalSize.height > pageContentSize.height) {
267             heightRatio = pageContentSize.height / originalSize.height;
268         }
269         ratio = Math.min(widthRatio, heightRatio);
270         return {
271             width: originalSize.width * ratio,
272             height: originalSize.height * ratio
273         };
274     }
275 
276     /**
277      * @param {!string} mimetype
278      * @param {!string} content base64 encoded string
279      * @param {!number} widthInPx
280      * @param {!number} heightInPx
281      * @return {undefined}
282      */
283     this.insertImage = function (mimetype, content, widthInPx, heightInPx) {
284         if (!isEnabled) {
285             return;
286         }
287 
288         var paragraphElement,
289             styleName,
290             pageContentSize,
291             imageSize,
292             cssUnits = new core.CSSUnits();
293 
294         runtime.assert(widthInPx > 0 && heightInPx > 0, "Both width and height of the image should be greater than 0px.");
295         imageSize = {
296             width: widthInPx,
297             height: heightInPx
298         };
299         // TODO: resize the image to fit in a cell if paragraphElement is in a table-cell
300         paragraphElement = odfUtils.getParagraphElement(odtDocument.getCursor(inputMemberId).getNode());
301         styleName = paragraphElement.getAttributeNS(textns, 'style-name');
302         if (styleName) {
303             // TODO cope with no paragraph style name being specified (i.e., use the default paragraph style)
304             pageContentSize = formatting.getContentSize(styleName, 'paragraph');
305             imageSize = scaleToAvailableContentSize(imageSize, pageContentSize);
306         }
307 
308         /* LO seems to be unable to digest px width and heights for image frames, and instead shows such
309            images as being 0.22" squares. To avoid that, let's use cm dimensions.
310          */
311         insertImageInternal(mimetype, content,
312             cssUnits.convert(imageSize.width, "px", "cm") + "cm",
313             cssUnits.convert(imageSize.height, "px", "cm") + "cm"
314         );
315     };
316 
317     /**
318      * @param {!function(!Error=)} callback, passing an error object in case of error
319      * @return {undefined}
320      */
321     this.destroy = function (callback) {
322         odtDocument.unsubscribe(ops.Document.signalCursorMoved, onCursorEvent);
323         sessionConstraints.unsubscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState);
324         callback();
325     };
326 
327     function init() {
328         odtDocument.subscribe(ops.Document.signalCursorMoved, onCursorEvent);
329         sessionConstraints.subscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState);
330         updateEnabledState();
331     }
332     init();
333 };
334 
335 /**@const*/gui.ImageController.enabledChanged = "enabled/changed";
336