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         formatting = odtDocument.getFormatting(),
 56         eventNotifier = new core.EventNotifier([
 57             gui.HyperlinkController.enabledChanged
 58         ]),
 59         isEnabled = false;
 60 
 61     /**
 62      * @return {undefined}
 63      */
 64     function updateEnabledState() {
 65         var /**@type{!boolean}*/newIsEnabled = true;
 66 
 67         if (sessionConstraints.getState(gui.CommonConstraints.EDIT.REVIEW_MODE) === true) {
 68             newIsEnabled = /**@type{!boolean}*/(sessionContext.isLocalCursorWithinOwnAnnotation());
 69         }
 70 
 71         if (newIsEnabled !== isEnabled) {
 72             isEnabled = newIsEnabled;
 73             eventNotifier.emit(gui.ImageController.enabledChanged, isEnabled);
 74         }
 75     }
 76 
 77     /**
 78      * @param {!ops.OdtCursor} cursor
 79      * @return {undefined}
 80      */
 81     function onCursorEvent(cursor) {
 82         if (cursor.getMemberId() === inputMemberId) {
 83             updateEnabledState();
 84         }
 85     }
 86 
 87     /**
 88      * @return {!boolean}
 89      */
 90     this.isEnabled = function () {
 91         return isEnabled;
 92     };
 93 
 94     /**
 95      * @param {!string} eventid
 96      * @param {!Function} cb
 97      * @return {undefined}
 98      */
 99     this.subscribe = function (eventid, cb) {
100         eventNotifier.subscribe(eventid, cb);
101     };
102 
103     /**
104      * @param {!string} eventid
105      * @param {!Function} cb
106      * @return {undefined}
107      */
108     this.unsubscribe = function (eventid, cb) {
109         eventNotifier.unsubscribe(eventid, cb);
110     };
111 
112 
113     /**
114      * @param {!string} name
115      * @return {!ops.Operation}
116      */
117     function createAddGraphicsStyleOp(name) {
118         var op = new ops.OpAddStyle();
119         op.init({
120             memberid: inputMemberId,
121             styleName: name,
122             styleFamily: 'graphic',
123             isAutomaticStyle: false,
124             setProperties: {
125                 "style:graphic-properties": {
126                     "text:anchor-type": "paragraph",
127                     "svg:x": "0cm",
128                     "svg:y": "0cm",
129                     "style:wrap": "dynamic",
130                     "style:number-wrapped-paragraphs": "no-limit",
131                     "style:wrap-contour": "false",
132                     "style:vertical-pos": "top",
133                     "style:vertical-rel": "paragraph",
134                     "style:horizontal-pos": "center",
135                     "style:horizontal-rel": "paragraph"
136                 }
137             }
138         });
139         return op;
140     }
141 
142     /**
143      * @param {!string} styleName
144      * @param {!string} parentStyleName
145      * @return {!ops.Operation}
146      */
147     function createAddFrameStyleOp(styleName, parentStyleName) {
148         var op = new ops.OpAddStyle();
149         op.init({
150             memberid: inputMemberId,
151             styleName: styleName,
152             styleFamily: 'graphic',
153             isAutomaticStyle: true,
154             setProperties: {
155                 "style:parent-style-name": parentStyleName,
156                 // a list of properties would be generated by default when inserting a image in LO.
157                 // They have no UI impacts in webodf, but copied here in case LO requires them to display image correctly.
158                 "style:graphic-properties": {
159                     "style:vertical-pos": "top",
160                     "style:vertical-rel": "baseline",
161                     "style:horizontal-pos": "center",
162                     "style:horizontal-rel": "paragraph",
163                     "fo:background-color": "transparent",
164                     "style:background-transparency": "100%",
165                     "style:shadow": "none",
166                     "style:mirror": "none",
167                     "fo:clip": "rect(0cm, 0cm, 0cm, 0cm)",
168                     "draw:luminance": "0%",
169                     "draw:contrast": "0%",
170                     "draw:red": "0%",
171                     "draw:green": "0%",
172                     "draw:blue": "0%",
173                     "draw:gamma": "100%",
174                     "draw:color-inversion": "false",
175                     "draw:image-opacity": "100%",
176                     "draw:color-mode": "standard"
177                 }
178             }
179         });
180         return op;
181     }
182 
183     /**
184      * @param {!string} mimetype
185      * @return {?string}
186      */
187     function getFileExtension(mimetype) {
188         mimetype = mimetype.toLowerCase();
189         return fileExtensionByMimetype.hasOwnProperty(mimetype) ? fileExtensionByMimetype[mimetype] : null;
190     }
191 
192     /**
193      * @param {!string} mimetype
194      * @param {!string} content base64 encoded string
195      * @param {!string} widthMeasure Width + units of the image
196      * @param {!string} heightMeasure Height + units of the image
197      * @return {undefined}
198      */
199     function insertImageInternal(mimetype, content, widthMeasure, heightMeasure) {
200         var /**@const@type{!string}*/graphicsStyleName = "Graphics",
201             stylesElement = odtDocument.getOdfCanvas().odfContainer().rootElement.styles,
202             fileExtension = getFileExtension(mimetype),
203             fileName,
204             graphicsStyleElement,
205             frameStyleName,
206             op, operations = [];
207 
208         runtime.assert(fileExtension !== null, "Image type is not supported: " + mimetype);
209         fileName = "Pictures/" + objectNameGenerator.generateImageName() + fileExtension;
210 
211         // TODO: eliminate duplicate image
212         op = new ops.OpSetBlob();
213         op.init({
214             memberid: inputMemberId,
215             filename: fileName,
216             mimetype: mimetype,
217             content: content
218         });
219         operations.push(op);
220 
221         // Add the 'Graphics' style if it does not exist in office:styles. It is required by LO to popup the
222         // picture option dialog when double clicking the image
223         // TODO: in collab mode this can result in unsolvable conflict if two add this style at the same time
224         graphicsStyleElement = formatting.getStyleElement(graphicsStyleName, "graphic", [stylesElement]);
225         if (!graphicsStyleElement) {
226             op = createAddGraphicsStyleOp(graphicsStyleName);
227             operations.push(op);
228         }
229 
230         // TODO: reuse an existing graphic style (if there is one) that has same style as default;
231         frameStyleName = objectNameGenerator.generateStyleName();
232         op = createAddFrameStyleOp(frameStyleName, graphicsStyleName);
233         operations.push(op);
234 
235         op = new ops.OpInsertImage();
236         op.init({
237             memberid: inputMemberId,
238             position: odtDocument.getCursorPosition(inputMemberId),
239             filename: fileName,
240             frameWidth: widthMeasure,
241             frameHeight: heightMeasure,
242             frameStyleName: frameStyleName,
243             frameName: objectNameGenerator.generateFrameName()
244         });
245         operations.push(op);
246 
247         session.enqueue(operations);
248     }
249 
250     /**
251      * Scales the supplied image rect to fit within the page content horizontal
252      * and vertical limits, whilst preserving the aspect ratio.
253      *
254      * @param {!{width: number, height: number}} originalSize
255      * @param {!{width: number, height: number}} pageContentSize
256      * @return {!{width: number, height: number}}
257      */
258     function scaleToAvailableContentSize(originalSize, pageContentSize) {
259         var widthRatio = 1,
260             heightRatio = 1,
261             ratio;
262         if (originalSize.width > pageContentSize.width) {
263             widthRatio = pageContentSize.width / originalSize.width;
264         }
265         if (originalSize.height > pageContentSize.height) {
266             heightRatio = pageContentSize.height / originalSize.height;
267         }
268         ratio = Math.min(widthRatio, heightRatio);
269         return {
270             width: originalSize.width * ratio,
271             height: originalSize.height * ratio
272         };
273     }
274 
275     /**
276      * @param {!string} mimetype
277      * @param {!string} content base64 encoded string
278      * @param {!number} widthInPx
279      * @param {!number} heightInPx
280      * @return {undefined}
281      */
282     this.insertImage = function (mimetype, content, widthInPx, heightInPx) {
283         if (!isEnabled) {
284             return;
285         }
286 
287         var paragraphElement,
288             styleName,
289             pageContentSize,
290             imageSize;
291 
292         runtime.assert(widthInPx > 0 && heightInPx > 0, "Both width and height of the image should be greater than 0px.");
293         imageSize = {
294             width: widthInPx,
295             height: heightInPx
296         };
297         // TODO: resize the image to fit in a cell if paragraphElement is in a table-cell
298         paragraphElement = odtDocument.getParagraphElement(odtDocument.getCursor(inputMemberId).getNode());
299         styleName = paragraphElement.getAttributeNS(textns, 'style-name');
300         if (styleName) {
301             // TODO cope with no paragraph style name being specified (i.e., use the default paragraph style)
302             pageContentSize = formatting.getContentSize(styleName, 'paragraph');
303             imageSize = scaleToAvailableContentSize(imageSize, pageContentSize);
304         }
305 
306         insertImageInternal(mimetype, content, imageSize.width + "px", imageSize.height + "px");
307     };
308 
309     /**
310      * @param {!function(!Error=)} callback, passing an error object in case of error
311      * @return {undefined}
312      */
313     this.destroy = function (callback) {
314         odtDocument.unsubscribe(ops.Document.signalCursorMoved, onCursorEvent);
315         sessionConstraints.unsubscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState);
316         callback();
317     };
318 
319     function init() {
320         odtDocument.subscribe(ops.Document.signalCursorMoved, onCursorEvent);
321         sessionConstraints.subscribe(gui.CommonConstraints.EDIT.REVIEW_MODE, updateEnabledState);
322         updateEnabledState();
323     }
324     init();
325 };
326 
327 /**@const*/gui.ImageController.enabledChanged = "enabled/changed";
328