1 /* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
2 /*
3  * This file is part of the LibreOffice project.
4  *
5  * This Source Code Form is subject to the terms of the Mozilla Public
6  * License, v. 2.0. If a copy of the MPL was not distributed with this
7  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
8  *
9  * This file incorporates work covered by the following license notice:
10  *
11  *   Licensed to the Apache Software Foundation (ASF) under one or more
12  *   contributor license agreements. See the NOTICE file distributed
13  *   with this work for additional information regarding copyright
14  *   ownership. The ASF licenses this file to you under the Apache
15  *   License, Version 2.0 (the "License"); you may not use this file
16  *   except in compliance with the License. You may obtain a copy of
17  *   the License at http://www.apache.org/licenses/LICENSE-2.0 .
18  */
19 
20 #include <screenshotannotationdlg.hxx>
21 
22 #include <strings.hrc>
23 #include <dialmgr.hxx>
24 
25 #include <basegfx/range/b2irange.hxx>
26 #include <com/sun/star/ui/dialogs/TemplateDescription.hpp>
27 #include <com/sun/star/ui/dialogs/ExecutableDialogResults.hpp>
28 #include <com/sun/star/ui/dialogs/XFilePicker3.hpp>
29 
30 #include <comphelper/random.hxx>
31 #include <basegfx/polygon/b2dpolygontools.hxx>
32 #include <sfx2/filedlghelper.hxx>
33 #include <tools/stream.hxx>
34 #include <tools/urlobj.hxx>
35 #include <vcl/bitmapex.hxx>
36 #include <vcl/customweld.hxx>
37 #include <vcl/event.hxx>
38 #include <vcl/pngwrite.hxx>
39 #include <vcl/svapp.hxx>
40 #include <vcl/salgtype.hxx>
41 #include <vcl/virdev.hxx>
42 #include <vcl/weld.hxx>
43 #include <svtools/optionsdrawinglayer.hxx>
44 #include <basegfx/matrix/b2dhommatrix.hxx>
45 #include <set>
46 #include <string_view>
47 
48 using namespace com::sun::star;
49 
50 namespace
51 {
lcl_genRandom(std::u16string_view rId)52     OUString lcl_genRandom( std::u16string_view rId )
53     {
54         //FIXME: plus timestamp
55         unsigned int nRand = comphelper::rng::uniform_uint_distribution(0, 0xFFFF);
56         return OUString( rId + OUString::number( nRand ) );
57     }
58 
59 
lcl_AltDescr()60     OUString lcl_AltDescr()
61     {
62         OUString aTempl("<alt id=\"%1\">"
63                         " " //FIXME real dialog title or something
64                         "</alt>");
65         aTempl = aTempl.replaceFirst( "%1", lcl_genRandom(u"alt_id") );
66 
67         return aTempl;
68     }
69 
lcl_Image(std::u16string_view rScreenshotId,const Size & rSize)70     OUString lcl_Image( std::u16string_view rScreenshotId, const Size& rSize )
71     {
72         OUString aTempl("<image id=\"%1\" src=\"media/screenshots/%2.png\""
73                            " width=\"%3cm\"  height=\"%4cm\">"
74                            "%5"
75                         "</image>");
76         aTempl = aTempl.replaceFirst( "%1", lcl_genRandom(u"img_id") );
77         aTempl = aTempl.replaceFirst( "%2", rScreenshotId );
78         aTempl = aTempl.replaceFirst( "%3", OUString::number( rSize.Width() ) );
79         aTempl = aTempl.replaceFirst( "%4", OUString::number( rSize.Height() ) );
80         aTempl = aTempl.replaceFirst( "%5", lcl_AltDescr() );
81 
82         return aTempl;
83     }
84 
lcl_ParagraphWithImage(std::u16string_view rScreenshotId,const Size & rSize)85     OUString lcl_ParagraphWithImage( std::u16string_view rScreenshotId, const Size& rSize )
86     {
87         OUString aTempl( "<paragraph id=\"%1\" role=\"paragraph\">%2"
88                          "</paragraph>"  SAL_NEWLINE_STRING );
89         aTempl = aTempl.replaceFirst( "%1", lcl_genRandom(u"par_id") );
90         aTempl = aTempl.replaceFirst( "%2", lcl_Image(rScreenshotId, rSize) );
91 
92         return aTempl;
93     }
94 
lcl_Bookmark(std::u16string_view rWidgetId)95     OUString lcl_Bookmark( std::u16string_view rWidgetId )
96     {
97         OUString aTempl = "<!-- Bookmark for widget %1 -->" SAL_NEWLINE_STRING
98                           "<bookmark branch=\"hid/%2\" id=\"%3\" localize=\"false\"/>" SAL_NEWLINE_STRING;
99         aTempl = aTempl.replaceFirst( "%1", rWidgetId );
100         aTempl = aTempl.replaceFirst( "%2", rWidgetId );
101         aTempl = aTempl.replaceFirst( "%3", lcl_genRandom(u"bm_id") );
102 
103         return aTempl;
104     }
105 }
106 
107 namespace
108 {
109     class Picture : public weld::CustomWidgetController
110     {
111     private:
112         ScreenshotAnnotationDlg_Impl *m_pDialog;
113         bool m_bMouseOver;
114     private:
115         virtual void Paint(vcl::RenderContext& rRenderContext, const tools::Rectangle&) override;
116         virtual bool MouseMove(const MouseEvent& rMouseEvent) override;
117         virtual bool MouseButtonUp(const MouseEvent& rMouseEvent) override;
118     public:
Picture(ScreenshotAnnotationDlg_Impl * pDialog)119         Picture(ScreenshotAnnotationDlg_Impl* pDialog)
120             : m_pDialog(pDialog)
121             , m_bMouseOver(false)
122         {
123         }
124 
IsMouseOver() const125         bool IsMouseOver() const
126         {
127             return m_bMouseOver;
128         }
129     };
130 }
131 
132 class ScreenshotAnnotationDlg_Impl
133 {
134 public:
135     ScreenshotAnnotationDlg_Impl(
136         weld::Window* pParent,
137         weld::Builder& rParent,
138         weld::Dialog& rParentDialog);
139     ~ScreenshotAnnotationDlg_Impl();
140 
141 private:
142     // Handler for click on save
143     DECL_LINK(saveButtonHandler, weld::Button&, void);
144 
145     // helper methods
146     weld::ScreenShotEntry* CheckHit(const basegfx::B2IPoint& rPosition);
147     void PaintScreenShotEntry(
148         const weld::ScreenShotEntry& rEntry,
149         const Color& rColor,
150         double fLineWidth,
151         double fTransparency);
152     void RepaintToBuffer(
153         bool bUseDimmed = false,
154         bool bPaintHilight = false);
155     void RepaintPictureElement();
156     Point GetOffsetInPicture() const;
157 
158     // local variables
159     weld::Window*               mpParentWindow;
160     weld::Dialog&               mrParentDialog;
161     BitmapEx                    maParentDialogBitmap;
162     BitmapEx                    maDimmedDialogBitmap;
163     Size                        maParentDialogSize;
164 
165     // VirtualDevice for buffered interaction paints
166     VclPtr<VirtualDevice>       mxVirtualBufferDevice;
167 
168     // all detected children
169     weld::ScreenShotCollection  maAllChildren;
170 
171     // highlighted/selected children
172     weld::ScreenShotEntry*           mpHilighted;
173     std::set< weld::ScreenShotEntry* >
174                                 maSelected;
175 
176     // list of detected controls
177     Picture maPicture;
178     std::unique_ptr<weld::CustomWeld> mxPicture;
179     std::unique_ptr<weld::TextView> mxText;
180     std::unique_ptr<weld::Button> mxSave;
181 
182     // save as text
183     OUString                    maSaveAsText;
184     OUString                    maMainMarkupText;
185 
186     // folder URL
187     static OUString             maLastFolderURL;
188 public:
189     void Paint(vcl::RenderContext& rRenderContext);
190     bool MouseMove(const MouseEvent& rMouseEvent);
191     bool MouseButtonUp();
192 };
193 
194 OUString ScreenshotAnnotationDlg_Impl::maLastFolderURL = OUString();
195 
ScreenshotAnnotationDlg_Impl(weld::Window * pParent,weld::Builder & rParentBuilder,weld::Dialog & rParentDialog)196 ScreenshotAnnotationDlg_Impl::ScreenshotAnnotationDlg_Impl(
197     weld::Window* pParent,
198     weld::Builder& rParentBuilder,
199     weld::Dialog& rParentDialog)
200 :   mpParentWindow(pParent),
201     mrParentDialog(rParentDialog),
202     mxVirtualBufferDevice(nullptr),
203     maAllChildren(),
204     mpHilighted(nullptr),
205     maSelected(),
206     maPicture(this),
207     maSaveAsText(CuiResId(RID_SVXSTR_SAVE_SCREENSHOT_AS))
208 {
209     VclPtr<VirtualDevice> xParentDialogSurface(rParentDialog.screenshot());
210     maParentDialogSize = xParentDialogSurface->GetOutputSizePixel();
211     maParentDialogBitmap = xParentDialogSurface->GetBitmapEx(Point(), maParentDialogSize);
212     maDimmedDialogBitmap = maParentDialogBitmap;
213 
214     // image ain't empty
215     assert(!maParentDialogBitmap.IsEmpty());
216     assert(0 != maParentDialogBitmap.GetSizePixel().Width());
217     assert(0 != maParentDialogBitmap.GetSizePixel().Height());
218 
219     // get needed widgets
220     mxPicture.reset(new weld::CustomWeld(rParentBuilder, "picture", maPicture));
221     assert(mxPicture);
222     mxText = rParentBuilder.weld_text_view("text");
223     assert(mxText);
224     mxSave = rParentBuilder.weld_button("save");
225     assert(mxSave);
226 
227     // set screenshot image at DrawingArea, resize, set event listener
228     if (mxPicture)
229     {
230         maAllChildren = mrParentDialog.collect_screenshot_data();
231 
232         // to make clear that maParentDialogBitmap is a background image, adjust
233         // luminance a bit for maDimmedDialogBitmap - other methods may be applied
234         maDimmedDialogBitmap.Adjust(-15, 0, 0, 0, 0);
235 
236         // init paint buffering VirtualDevice
237         mxVirtualBufferDevice = VclPtr<VirtualDevice>::Create(*Application::GetDefaultDevice(), DeviceFormat::DEFAULT);
238         mxVirtualBufferDevice->SetOutputSizePixel(maParentDialogSize);
239         mxVirtualBufferDevice->SetFillColor(COL_TRANSPARENT);
240 
241         // initially set image for picture control
242         mxVirtualBufferDevice->DrawBitmapEx(Point(0, 0), maDimmedDialogBitmap);
243 
244         // set size for picture control, this will re-layout so that
245         // the picture control shows the whole dialog
246         maPicture.SetOutputSizePixel(maParentDialogSize);
247         mxPicture->set_size_request(maParentDialogSize.Width(), maParentDialogSize.Height());
248 
249         mxPicture->queue_draw();
250     }
251 
252     // set some test text at VclMultiLineEdit and make read-only - only
253     // copying content to clipboard is allowed
254     if (mxText)
255     {
256         mxText->set_size_request(400, mxText->get_height_rows(10));
257         OUString aHelpId = OStringToOUString( mrParentDialog.get_help_id(), RTL_TEXTENCODING_UTF8 );
258         Size aSizeCm = Application::GetDefaultDevice()->PixelToLogic(maParentDialogSize, MapMode(MapUnit::MapCM));
259         maMainMarkupText = lcl_ParagraphWithImage( aHelpId, aSizeCm );
260         mxText->set_text( maMainMarkupText );
261         mxText->set_editable(false);
262     }
263 
264     // set click handler for save button
265     if (mxSave)
266     {
267         mxSave->connect_clicked(LINK(this, ScreenshotAnnotationDlg_Impl, saveButtonHandler));
268     }
269 }
270 
~ScreenshotAnnotationDlg_Impl()271 ScreenshotAnnotationDlg_Impl::~ScreenshotAnnotationDlg_Impl()
272 {
273     mxVirtualBufferDevice.disposeAndClear();
274 }
275 
IMPL_LINK_NOARG(ScreenshotAnnotationDlg_Impl,saveButtonHandler,weld::Button &,void)276 IMPL_LINK_NOARG(ScreenshotAnnotationDlg_Impl, saveButtonHandler, weld::Button&, void)
277 {
278     // 'save screenshot...' pressed, offer to save maParentDialogBitmap
279     // as PNG image, use *.id file name as screenshot file name offering
280     // get a suggestion for the filename from buildable name
281     OString aDerivedFileName = mrParentDialog.get_buildable_name();
282 
283     auto xFileDlg = std::make_unique<sfx2::FileDialogHelper>(ui::dialogs::TemplateDescription::FILESAVE_AUTOEXTENSION,
284                                                              FileDialogFlags::NONE, mpParentWindow);
285 
286     const uno::Reference< ui::dialogs::XFilePicker3 > xFilePicker = xFileDlg->GetFilePicker();
287 
288     xFilePicker->setTitle(maSaveAsText);
289 
290     if (!maLastFolderURL.isEmpty())
291     {
292         xFilePicker->setDisplayDirectory(maLastFolderURL);
293     }
294 
295     xFilePicker->appendFilter("*.png", "*.png");
296     xFilePicker->setCurrentFilter("*.png");
297     xFilePicker->setDefaultName(OStringToOUString(aDerivedFileName, RTL_TEXTENCODING_UTF8));
298     xFilePicker->setMultiSelectionMode(false);
299 
300     if (xFilePicker->execute() != ui::dialogs::ExecutableDialogResults::OK)
301         return;
302 
303     maLastFolderURL = xFilePicker->getDisplayDirectory();
304     const uno::Sequence< OUString > files(xFilePicker->getSelectedFiles());
305 
306     if (!files.hasElements())
307         return;
308 
309     OUString aConfirmedName = files[0];
310 
311     if (aConfirmedName.isEmpty())
312         return;
313 
314     INetURLObject aConfirmedURL(aConfirmedName);
315     OUString aCurrentExtension(aConfirmedURL.getExtension());
316 
317     if (!aCurrentExtension.isEmpty() && aCurrentExtension != "png")
318     {
319         aConfirmedURL.removeExtension();
320         aCurrentExtension.clear();
321     }
322 
323     if (aCurrentExtension.isEmpty())
324     {
325         aConfirmedURL.setExtension(u"png");
326     }
327 
328     // open stream
329     SvFileStream aNew(aConfirmedURL.PathToFileName(), StreamMode::WRITE | StreamMode::TRUNC);
330 
331     if (!aNew.IsOpen())
332         return;
333 
334     // prepare bitmap to save - do use the original screenshot here,
335     // not the dimmed one
336     RepaintToBuffer();
337 
338     // extract Bitmap
339     const BitmapEx aTargetBitmap(
340         mxVirtualBufferDevice->GetBitmapEx(
341         Point(0, 0),
342         mxVirtualBufferDevice->GetOutputSizePixel()));
343 
344     // write as PNG
345     vcl::PNGWriter aPNGWriter(aTargetBitmap);
346     aPNGWriter.Write(aNew);
347 }
348 
CheckHit(const basegfx::B2IPoint & rPosition)349 weld::ScreenShotEntry* ScreenshotAnnotationDlg_Impl::CheckHit(const basegfx::B2IPoint& rPosition)
350 {
351     weld::ScreenShotEntry* pRetval = nullptr;
352 
353     for (auto&& rCandidate : maAllChildren)
354     {
355         if (rCandidate.getB2IRange().isInside(rPosition))
356         {
357             if (pRetval)
358             {
359                 if (pRetval->getB2IRange().isInside(rCandidate.getB2IRange().getMinimum())
360                     && pRetval->getB2IRange().isInside(rCandidate.getB2IRange().getMaximum()))
361                 {
362                     pRetval = &rCandidate;
363                 }
364             }
365             else
366             {
367                 pRetval = &rCandidate;
368             }
369         }
370     }
371 
372     return pRetval;
373 }
374 
PaintScreenShotEntry(const weld::ScreenShotEntry & rEntry,const Color & rColor,double fLineWidth,double fTransparency)375 void ScreenshotAnnotationDlg_Impl::PaintScreenShotEntry(
376     const weld::ScreenShotEntry& rEntry,
377     const Color& rColor,
378     double fLineWidth,
379     double fTransparency)
380 {
381     if (!(mxPicture && mxVirtualBufferDevice))
382         return;
383 
384     basegfx::B2DRange aB2DRange(rEntry.getB2IRange());
385 
386     // grow in pixels to be a little bit 'outside'. This also
387     // ensures that getWidth()/getHeight() ain't 0.0 (see division below)
388     static const double fGrowTopLeft(1.5);
389     static const double fGrowBottomRight(0.5);
390     aB2DRange.expand(aB2DRange.getMinimum() - basegfx::B2DPoint(fGrowTopLeft, fGrowTopLeft));
391     aB2DRange.expand(aB2DRange.getMaximum() + basegfx::B2DPoint(fGrowBottomRight, fGrowBottomRight));
392 
393     // edge rounding in pixel. Need to convert, value for
394     // createPolygonFromRect is relative [0.0 .. 1.0]
395     static const double fEdgeRoundPixel(8.0);
396     const basegfx::B2DPolygon aPolygon(
397         basegfx::utils::createPolygonFromRect(
398         aB2DRange,
399         fEdgeRoundPixel / aB2DRange.getWidth(),
400         fEdgeRoundPixel / aB2DRange.getHeight()));
401 
402     mxVirtualBufferDevice->SetLineColor(rColor);
403 
404     // try to use transparency
405     if (!mxVirtualBufferDevice->DrawPolyLineDirect(
406         basegfx::B2DHomMatrix(),
407         aPolygon,
408         fLineWidth,
409         fTransparency,
410         nullptr, // MM01
411         basegfx::B2DLineJoin::Round))
412     {
413         // no transparency, draw without
414         mxVirtualBufferDevice->DrawPolyLine(
415             aPolygon,
416             fLineWidth);
417     }
418 }
419 
GetOffsetInPicture() const420 Point ScreenshotAnnotationDlg_Impl::GetOffsetInPicture() const
421 {
422     const Size aPixelSizeTarget(maPicture.GetOutputSizePixel());
423 
424     return Point(
425         aPixelSizeTarget.Width() > maParentDialogSize.Width() ? (aPixelSizeTarget.Width() - maParentDialogSize.Width()) >> 1 : 0,
426         aPixelSizeTarget.Height() > maParentDialogSize.Height() ? (aPixelSizeTarget.Height() - maParentDialogSize.Height()) >> 1 : 0);
427 }
428 
RepaintToBuffer(bool bUseDimmed,bool bPaintHilight)429 void ScreenshotAnnotationDlg_Impl::RepaintToBuffer(
430     bool bUseDimmed,
431     bool bPaintHilight)
432 {
433     if (!mxVirtualBufferDevice)
434         return;
435 
436     // reset with original screenshot bitmap
437     mxVirtualBufferDevice->DrawBitmapEx(
438         Point(0, 0),
439         bUseDimmed ? maDimmedDialogBitmap : maParentDialogBitmap);
440 
441     // get various options
442     const SvtOptionsDrawinglayer aSvtOptionsDrawinglayer;
443     const Color aHilightColor(aSvtOptionsDrawinglayer.getHilightColor());
444     const double fTransparence(aSvtOptionsDrawinglayer.GetTransparentSelectionPercent() * 0.01);
445     const bool bIsAntiAliasing(aSvtOptionsDrawinglayer.IsAntiAliasing());
446     const AntialiasingFlags nOldAA(mxVirtualBufferDevice->GetAntialiasing());
447 
448     if (bIsAntiAliasing)
449     {
450         mxVirtualBufferDevice->SetAntialiasing(AntialiasingFlags::Enable);
451     }
452 
453     // paint selected entries
454     for (auto&& rCandidate : maSelected)
455     {
456         static const double fLineWidthEntries(5.0);
457         PaintScreenShotEntry(*rCandidate, COL_LIGHTRED, fLineWidthEntries, fTransparence * 0.2);
458     }
459 
460     // paint highlighted entry
461     if (mpHilighted && bPaintHilight)
462     {
463         static const double fLineWidthHilight(7.0);
464         PaintScreenShotEntry(*mpHilighted, aHilightColor, fLineWidthHilight, fTransparence);
465     }
466 
467     if (bIsAntiAliasing)
468     {
469         mxVirtualBufferDevice->SetAntialiasing(nOldAA);
470     }
471 }
472 
RepaintPictureElement()473 void ScreenshotAnnotationDlg_Impl::RepaintPictureElement()
474 {
475     if (mxPicture && mxVirtualBufferDevice)
476     {
477         // reset image in buffer, use dimmed version and allow highlight
478         RepaintToBuffer(true, true);
479         mxPicture->queue_draw();
480     }
481 }
482 
Paint(vcl::RenderContext & rRenderContext)483 void ScreenshotAnnotationDlg_Impl::Paint(vcl::RenderContext& rRenderContext)
484 {
485     Point aPos(GetOffsetInPicture());
486     Size aSize(mxVirtualBufferDevice->GetOutputSizePixel());
487     rRenderContext.DrawOutDev(aPos, aSize, Point(), aSize, *mxVirtualBufferDevice);
488 }
489 
Paint(vcl::RenderContext & rRenderContext,const tools::Rectangle &)490 void Picture::Paint(vcl::RenderContext& rRenderContext, const tools::Rectangle&)
491 {
492     m_pDialog->Paint(rRenderContext);
493 }
494 
MouseMove(const MouseEvent & rMouseEvent)495 bool ScreenshotAnnotationDlg_Impl::MouseMove(const MouseEvent& rMouseEvent)
496 {
497     bool bRepaint(false);
498 
499     if (maPicture.IsMouseOver())
500     {
501         const weld::ScreenShotEntry* pOldHit = mpHilighted;
502         const Point aOffset(GetOffsetInPicture());
503         const basegfx::B2IPoint aMousePos(
504             rMouseEvent.GetPosPixel().X() - aOffset.X(),
505             rMouseEvent.GetPosPixel().Y() - aOffset.Y());
506         const weld::ScreenShotEntry* pHit = CheckHit(aMousePos);
507 
508         if (pHit && pOldHit != pHit)
509         {
510             mpHilighted = const_cast<weld::ScreenShotEntry*>(pHit);
511             bRepaint = true;
512         }
513     }
514     else if (mpHilighted)
515     {
516         mpHilighted = nullptr;
517         bRepaint = true;
518     }
519 
520     if (bRepaint)
521     {
522         RepaintPictureElement();
523     }
524 
525     return true;
526 }
527 
MouseMove(const MouseEvent & rMouseEvent)528 bool Picture::MouseMove(const MouseEvent& rMouseEvent)
529 {
530     if (rMouseEvent.IsEnterWindow())
531         m_bMouseOver = true;
532     if (rMouseEvent.IsLeaveWindow())
533         m_bMouseOver = false;
534     return m_pDialog->MouseMove(rMouseEvent);
535 }
536 
MouseButtonUp()537 bool ScreenshotAnnotationDlg_Impl::MouseButtonUp()
538 {
539     // event in picture frame
540     bool bRepaint(false);
541 
542     if (maPicture.IsMouseOver() && mpHilighted)
543     {
544         if (maSelected.erase(mpHilighted) == 0)
545         {
546             maSelected.insert(mpHilighted);
547         }
548 
549         OUStringBuffer aBookmarks(maMainMarkupText);
550         for (auto&& rCandidate : maSelected)
551         {
552             OUString aHelpId = OStringToOUString( rCandidate->GetHelpId(), RTL_TEXTENCODING_UTF8 );
553             aBookmarks.append(lcl_Bookmark( aHelpId ));
554         }
555 
556         mxText->set_text( aBookmarks.makeStringAndClear() );
557         bRepaint = true;
558     }
559 
560     if (bRepaint)
561     {
562         RepaintPictureElement();
563     }
564 
565     return true;
566 }
567 
MouseButtonUp(const MouseEvent &)568 bool Picture::MouseButtonUp(const MouseEvent&)
569 {
570     return m_pDialog->MouseButtonUp();
571 }
572 
ScreenshotAnnotationDlg(weld::Dialog & rParentDialog)573 ScreenshotAnnotationDlg::ScreenshotAnnotationDlg(weld::Dialog& rParentDialog)
574     : GenericDialogController(&rParentDialog, "cui/ui/screenshotannotationdialog.ui", "ScreenshotAnnotationDialog")
575 {
576     m_pImpl.reset(new ScreenshotAnnotationDlg_Impl(m_xDialog.get(), *m_xBuilder, rParentDialog));
577 }
578 
~ScreenshotAnnotationDlg()579 ScreenshotAnnotationDlg::~ScreenshotAnnotationDlg()
580 {
581 }
582 
583 /* vim:set shiftwidth=4 softtabstop=4 expandtab: */
584