1 /* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
3 * This file is part of the LibreOffice project.
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/.
9 * This file incorporates work covered by the following license notice:
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 .
21 #include <canvas/debug.hxx>
22 #include <tools/diagnose_ex.h>
23 #include <rtl/math.hxx>
25 #include <com/sun/star/rendering/TexturingMode.hpp>
27 #include <basegfx/matrix/b2dhommatrix.hxx>
28 #include <basegfx/point/b2dpoint.hxx>
29 #include <basegfx/range/b2drectangle.hxx>
30 #include <basegfx/numeric/ftools.hxx>
31 #include <basegfx/polygon/b2dpolygontools.hxx>
32 #include <basegfx/tools/tools.hxx>
33 #include <basegfx/tools/lerp.hxx>
34 #include <basegfx/tools/keystoplerp.hxx>
35 #include <basegfx/tools/canvastools.hxx>
36 #include <basegfx/matrix/b2dhommatrixtools.hxx>
38 #include <canvas/parametricpolypolygon.hxx>
40 #include "dx_spritecanvas.hxx"
41 #include "dx_canvashelper.hxx"
42 #include "dx_impltools.hxx"
44 #include <boost/scoped_ptr.hpp>
45 #include <boost/bind.hpp>
46 #include <boost/tuple/tuple.hpp>
49 using namespace ::com::sun::star
;
55 typedef ::boost::shared_ptr
< Gdiplus::PathGradientBrush
> PathGradientBrushSharedPtr
;
57 bool fillLinearGradient( GraphicsSharedPtr
& rGraphics
,
58 const ::canvas::ParametricPolyPolygon::Values
& /*rValues*/,
59 const std::vector
< Gdiplus::Color
>& rColors
,
60 const std::vector
< Gdiplus::REAL
>& rStops
,
61 const GraphicsPathSharedPtr
& rFillPath
,
62 const rendering::Texture
& texture
)
64 // setup a linear gradient with given colors
67 Gdiplus::LinearGradientBrush
aBrush(
75 aBrush
.SetInterpolationColors(&rColors
[0],
79 // render background color, as LinearGradientBrush does not
80 // properly support the WrapModeClamp repeat mode
81 Gdiplus::SolidBrush
aBackgroundBrush( rColors
[0] );
82 rGraphics
->FillPath( &aBackgroundBrush
, rFillPath
.get() );
84 // TODO(F2): This does not yet support other repeat modes
85 // except clamp, and probably also no multi-texturing
87 // calculate parallelogram of gradient in object space, extend
88 // top and bottom of it such that they cover the whole fill
90 ::basegfx::B2DHomMatrix aTextureTransform
;
91 ::basegfx::unotools::homMatrixFromAffineMatrix( aTextureTransform
,
92 texture
.AffineTransform
);
94 ::basegfx::B2DPoint
aLeftTop( 0.0, 0.0 );
95 ::basegfx::B2DPoint
aLeftBottom( 0.0, 1.0 );
96 ::basegfx::B2DPoint
aRightTop( 1.0, 0.0 );
97 ::basegfx::B2DPoint
aRightBottom( 1.0, 1.0 );
99 aLeftTop
*= aTextureTransform
;
100 aLeftBottom
*= aTextureTransform
;
101 aRightTop
*= aTextureTransform
;
102 aRightBottom
*= aTextureTransform
;
104 Gdiplus::RectF aBounds
;
105 rFillPath
->GetBounds( &aBounds
, NULL
, NULL
);
107 // now, we potentially have to enlarge our gradient area
108 // atop and below the transformed [0,1]x[0,1] unit rect,
109 // for the gradient to fill the complete bound rect.
110 ::basegfx::tools::infiniteLineFromParallelogram( aLeftTop
,
114 tools::b2dRangeFromGdiPlusRectF( aBounds
) );
116 // calc length of bound rect diagonal
117 const double nDiagonalLength(
118 hypot( aBounds
.Width
,
121 // generate a path which covers the 'right' side of the
122 // gradient, extending two times the bound rect diagonal to
123 // the right (and thus covering the whole half plane 'right'
124 // of the gradient). Take the middle of the gradient as the
125 // 'left' side of the polygon, to not fall victim to rounding
126 // errors at the edge.
127 ::basegfx::B2DVector
aDirection( aLeftTop
- aLeftBottom
);
128 aDirection
= ::basegfx::getNormalizedPerpendicular( aDirection
);
129 aDirection
*= nDiagonalLength
;
131 const ::basegfx::B2DPoint
aHalfPlaneLeftTop( (aLeftTop
+ aRightTop
) * 0.5 );
132 const ::basegfx::B2DPoint
aHalfPlaneLeftBottom( (aLeftBottom
+ aRightBottom
) * 0.5 );
133 const ::basegfx::B2DPoint
aHalfPlaneRightTop( aRightTop
+ aDirection
);
134 const ::basegfx::B2DPoint
aHalfPlaneRightBottom( aRightBottom
+ aDirection
);
136 Gdiplus::GraphicsPath aSolidFillPath
;
137 aSolidFillPath
.AddLine( static_cast<Gdiplus::REAL
>(aHalfPlaneLeftTop
.getX()),
138 static_cast<Gdiplus::REAL
>(aHalfPlaneLeftTop
.getY()),
139 static_cast<Gdiplus::REAL
>(aHalfPlaneRightTop
.getX()),
140 static_cast<Gdiplus::REAL
>(aHalfPlaneRightTop
.getY()) );
141 aSolidFillPath
.AddLine( static_cast<Gdiplus::REAL
>(aHalfPlaneRightBottom
.getX()),
142 static_cast<Gdiplus::REAL
>(aHalfPlaneRightBottom
.getY()),
143 static_cast<Gdiplus::REAL
>(aHalfPlaneLeftBottom
.getX()),
144 static_cast<Gdiplus::REAL
>(aHalfPlaneLeftBottom
.getY()) );
145 aSolidFillPath
.CloseFigure();
147 // limit output to fill path, we've just generated a path that
148 // might be substantially larger
149 if( Gdiplus::Ok
!= rGraphics
->SetClip( rFillPath
.get(),
150 Gdiplus::CombineModeIntersect
) )
155 Gdiplus::SolidBrush
aBackgroundBrush2( rColors
.back() );
156 rGraphics
->FillPath( &aBackgroundBrush2
, &aSolidFillPath
);
158 // generate clip polygon from the extended parallelogram
159 // (exploit the feature that distinct lines in a figure are
160 // automatically closed by a straight line)
161 Gdiplus::GraphicsPath aClipPath
;
162 aClipPath
.AddLine( static_cast<Gdiplus::REAL
>(aLeftTop
.getX()),
163 static_cast<Gdiplus::REAL
>(aLeftTop
.getY()),
164 static_cast<Gdiplus::REAL
>(aRightTop
.getX()),
165 static_cast<Gdiplus::REAL
>(aRightTop
.getY()) );
166 aClipPath
.AddLine( static_cast<Gdiplus::REAL
>(aRightBottom
.getX()),
167 static_cast<Gdiplus::REAL
>(aRightBottom
.getY()),
168 static_cast<Gdiplus::REAL
>(aLeftBottom
.getX()),
169 static_cast<Gdiplus::REAL
>(aLeftBottom
.getY()) );
170 aClipPath
.CloseFigure();
172 // limit output to a _single_ strip of the gradient (have to
173 // clip here, since GDI+ wrapmode clamp does not work here)
174 if( Gdiplus::Ok
!= rGraphics
->SetClip( &aClipPath
,
175 Gdiplus::CombineModeIntersect
) )
180 // now, finally, output the gradient
181 Gdiplus::Matrix aMatrix
;
182 tools::gdiPlusMatrixFromAffineMatrix2D( aMatrix
,
183 texture
.AffineTransform
);
184 aBrush
.SetTransform( &aMatrix
);
186 rGraphics
->FillRectangle( &aBrush
, aBounds
);
191 int numColorSteps( const Gdiplus::Color
& rColor1
, const Gdiplus::Color
& rColor2
)
194 labs( rColor1
.GetRed() - rColor2
.GetRed() ),
196 labs( rColor1
.GetGreen() - rColor2
.GetGreen() ),
197 labs( rColor1
.GetBlue() - rColor2
.GetBlue() ) ) );
200 bool fillPolygonalGradient( const ::canvas::ParametricPolyPolygon::Values
& rValues
,
201 const std::vector
< Gdiplus::Color
>& rColors
,
202 const std::vector
< Gdiplus::REAL
>& rStops
,
203 GraphicsSharedPtr
& rGraphics
,
204 const GraphicsPathSharedPtr
& rPath
,
205 const rendering::ViewState
& viewState
,
206 const rendering::RenderState
& renderState
,
207 const rendering::Texture
& texture
)
209 // copy original fill path object, might have to change it
211 GraphicsPathSharedPtr
pFillPath( rPath
);
212 const ::basegfx::B2DPolygon
& rGradientPoly( rValues
.maGradientPoly
);
214 PathGradientBrushSharedPtr pGradientBrush
;
216 // fill background uniformly with end color
217 Gdiplus::SolidBrush
aBackgroundBrush( rColors
[0] );
218 rGraphics
->FillPath( &aBackgroundBrush
, pFillPath
.get() );
220 Gdiplus::Matrix aMatrix
;
221 // scale focus according to aspect ratio: for wider-than-tall
222 // bounds (nAspectRatio > 1.0), the focus must have non-zero
223 // width. Specifically, a bound rect twice as wide as tall has
224 // a focus of half its width.
225 if( !::rtl::math::approxEqual(rValues
.mnAspectRatio
,
230 // And here comes the greatest shortcoming of the GDI+
231 // gradients ever: SetFocusScales completely ignores
232 // transformations, both when set at the PathGradientBrush
233 // and for the world coordinate system. Thus, to correctly
234 // display anisotrophic path gradients, we have to render
235 // them by hand. WTF.
237 // TODO(F2): This does not yet support other repeat modes
238 // except clamp, and probably also no multi-texturing
240 // limit output to to-be-filled polygon
241 if( Gdiplus::Ok
!= rGraphics
->SetClip( pFillPath
.get(),
242 Gdiplus::CombineModeIntersect
) )
247 // disable anti-aliasing, if any
248 const Gdiplus::SmoothingMode
eOldAAMode( rGraphics
->GetSmoothingMode() );
249 rGraphics
->SetSmoothingMode( Gdiplus::SmoothingModeHighSpeed
);
252 // determine number of steps to use
255 // TODO(Q2): Unify step calculations with VCL canvas
257 for( size_t i
=0; i
<rColors
.size()-1; ++i
)
258 nColorSteps
+= numColorSteps(rColors
[i
],rColors
[i
+1]);
259 ::basegfx::B2DHomMatrix aTotalTransform
;
260 const int nStepCount
=
261 ::canvas::tools::calcGradientStepCount(aTotalTransform
,
267 ::basegfx::B2DHomMatrix aTextureTransform
;
268 ::basegfx::unotools::homMatrixFromAffineMatrix( aTextureTransform
,
269 texture
.AffineTransform
);
270 // determine overall transformation for inner polygon (might
271 // have to be prefixed by anisotrophic scaling)
272 ::basegfx::B2DHomMatrix aInnerPolygonTransformMatrix
;
274 // For performance reasons, we create a temporary VCL polygon
275 // here, keep it all the way and only change the vertex values
276 // in the loop below (as ::Polygon is a pimpl class, creating
277 // one every loop turn would really stress the mem allocator)
278 ::basegfx::B2DPolygon
aOuterPoly( rGradientPoly
);
279 ::basegfx::B2DPolygon aInnerPoly
;
281 // subdivide polygon _before_ rendering, would otherwise have
282 // to be performed on every loop turn.
283 if( aOuterPoly
.areControlPointsUsed() )
284 aOuterPoly
= ::basegfx::tools::adaptiveSubdivideByAngle(aOuterPoly
);
286 aInnerPoly
= aOuterPoly
;
287 aOuterPoly
.transform(aTextureTransform
);
290 // apply scaling (possibly anisotrophic) to inner polygon
293 // scale inner polygon according to aspect ratio: for
294 // wider-than-tall bounds (nAspectRatio > 1.0), the inner
295 // polygon, representing the gradient focus, must have
296 // non-zero width. Specifically, a bound rect twice as wide as
297 // tall has a focus polygon of half its width.
298 const double nAspectRatio( rValues
.mnAspectRatio
);
299 if( nAspectRatio
> 1.0 )
301 // width > height case
302 aInnerPolygonTransformMatrix
.scale( 1.0 - 1.0/nAspectRatio
,
305 else if( nAspectRatio
< 1.0 )
307 // width < height case
308 aInnerPolygonTransformMatrix
.scale( 0.0,
309 1.0 - nAspectRatio
);
314 aInnerPolygonTransformMatrix
.scale( 0.0, 0.0 );
317 // and finally, add texture transform to it.
318 aInnerPolygonTransformMatrix
*= aTextureTransform
;
320 // apply final matrix to polygon
321 aInnerPoly
.transform( aInnerPolygonTransformMatrix
);
323 Gdiplus::GraphicsPath aCurrPath
;
324 Gdiplus::SolidBrush
aFillBrush( rColors
[0] );
325 const sal_uInt32
nNumPoints( aOuterPoly
.count() );
326 basegfx::tools::KeyStopLerp
aLerper(rValues
.maStops
);
327 for( int i
=1; i
<nStepCount
; ++i
)
329 std::ptrdiff_t nIndex
;
331 const double fT( i
/double(nStepCount
) );
332 boost::tuples::tie(nIndex
,fAlpha
)=aLerper
.lerp(fT
);
334 const Gdiplus::Color
aFillColor(
335 static_cast<BYTE
>( basegfx::tools::lerp(rColors
[nIndex
].GetRed(),rColors
[nIndex
+1].GetRed(),fAlpha
) ),
336 static_cast<BYTE
>( basegfx::tools::lerp(rColors
[nIndex
].GetGreen(),rColors
[nIndex
+1].GetGreen(),fAlpha
) ),
337 static_cast<BYTE
>( basegfx::tools::lerp(rColors
[nIndex
].GetBlue(),rColors
[nIndex
+1].GetBlue(),fAlpha
) ) );
339 aFillBrush
.SetColor( aFillColor
);
340 aCurrPath
.Reset(); aCurrPath
.StartFigure();
341 for( unsigned int p
=1; p
<nNumPoints
; ++p
)
343 const ::basegfx::B2DPoint
& rOuterPoint1( aOuterPoly
.getB2DPoint(p
-1) );
344 const ::basegfx::B2DPoint
& rInnerPoint1( aInnerPoly
.getB2DPoint(p
-1) );
345 const ::basegfx::B2DPoint
& rOuterPoint2( aOuterPoly
.getB2DPoint(p
) );
346 const ::basegfx::B2DPoint
& rInnerPoint2( aInnerPoly
.getB2DPoint(p
) );
349 Gdiplus::REAL(fT
*rInnerPoint1
.getX() + (1-fT
)*rOuterPoint1
.getX()),
350 Gdiplus::REAL(fT
*rInnerPoint1
.getY() + (1-fT
)*rOuterPoint1
.getY()),
351 Gdiplus::REAL(fT
*rInnerPoint2
.getX() + (1-fT
)*rOuterPoint2
.getX()),
352 Gdiplus::REAL(fT
*rInnerPoint2
.getY() + (1-fT
)*rOuterPoint2
.getY()));
354 aCurrPath
.CloseFigure();
356 rGraphics
->FillPath( &aFillBrush
, &aCurrPath
);
359 // reset to old anti-alias mode
360 rGraphics
->SetSmoothingMode( eOldAAMode
);
366 // We're generating a PathGradientBrush from scratch here,
367 // and put in a transformed GraphicsPath (transformed with
368 // the texture transform). This is because the
369 // straight-forward approach to store a Brush pointer at
370 // this class and set a texture transform via
371 // PathGradientBrush::SetTransform() is spoiled by MS: it
372 // seems that _either_ the texture transform, _or_ the
373 // transform at the Graphics can be set, but not both. If
374 // one sets both, only the translational components of the
375 // texture is respected.
377 tools::gdiPlusMatrixFromAffineMatrix2D( aMatrix
,
378 texture
.AffineTransform
);
379 GraphicsPathSharedPtr
pGradientPath(
380 tools::graphicsPathFromB2DPolygon( rValues
.maGradientPoly
));
381 pGradientPath
->Transform( &aMatrix
);
383 pGradientBrush
.reset(
384 new Gdiplus::PathGradientBrush( pGradientPath
.get() ) );
385 pGradientBrush
->SetInterpolationColors( &rColors
[0],
389 // explicitly setup center point. Since the center of GDI+
390 // gradients are by default the _centroid_ of the path
391 // (i.e. the weighted sum of edge points), it will not
392 // necessarily coincide with our notion of center.
393 Gdiplus::PointF
aCenterPoint(0, 0);
394 aMatrix
.TransformPoints( &aCenterPoint
);
395 pGradientBrush
->SetCenterPoint( aCenterPoint
);
397 const bool bTileX( texture
.RepeatModeX
!= rendering::TexturingMode::CLAMP
);
398 const bool bTileY( texture
.RepeatModeY
!= rendering::TexturingMode::CLAMP
);
400 if( bTileX
&& bTileY
)
401 pGradientBrush
->SetWrapMode( Gdiplus::WrapModeTile
);
404 OSL_ENSURE( bTileY
== bTileX
,
405 "ParametricPolyPolygon::fillPolygonalGradient(): Cannot have repeat x and repeat y differ!" );
407 pGradientBrush
->SetWrapMode( Gdiplus::WrapModeClamp
);
410 // render actual gradient
411 rGraphics
->FillPath( pGradientBrush
.get(), pFillPath
.get() );
414 #if OSL_DEBUG_LEVEL > 2
415 Gdiplus::Pen
aPen( Gdiplus::Color( 255, 255, 0, 0 ),
418 rGraphics
->DrawRectangle( &aPen
,
419 Gdiplus::RectF( 0.0f
, 0.0f
,
426 bool fillGradient( const ::canvas::ParametricPolyPolygon::Values
& rValues
,
427 const std::vector
< Gdiplus::Color
>& rColors
,
428 const std::vector
< Gdiplus::REAL
>& rStops
,
429 GraphicsSharedPtr
& rGraphics
,
430 const GraphicsPathSharedPtr
& rPath
,
431 const rendering::ViewState
& viewState
,
432 const rendering::RenderState
& renderState
,
433 const rendering::Texture
& texture
)
435 switch( rValues
.meType
)
437 case ::canvas::ParametricPolyPolygon::GRADIENT_LINEAR
:
438 fillLinearGradient( rGraphics
,
446 case ::canvas::ParametricPolyPolygon::GRADIENT_ELLIPTICAL
:
447 // FALLTHROUGH intended
448 case ::canvas::ParametricPolyPolygon::GRADIENT_RECTANGULAR
:
449 fillPolygonalGradient( rValues
,
460 ENSURE_OR_THROW( false,
461 "CanvasHelper::fillGradient(): Unexpected case" );
467 void fillBitmap( const uno::Reference
< rendering::XBitmap
>& xBitmap
,
468 GraphicsSharedPtr
& rGraphics
,
469 const GraphicsPathSharedPtr
& rPath
,
470 const rendering::Texture
& rTexture
)
472 OSL_ENSURE( rTexture
.RepeatModeX
==
473 rTexture
.RepeatModeY
,
474 "CanvasHelper::fillBitmap(): GDI+ cannot handle differing X/Y repeat mode." );
476 const bool bClamp( rTexture
.RepeatModeX
== rendering::TexturingMode::NONE
&&
477 rTexture
.RepeatModeY
== rendering::TexturingMode::NONE
);
479 const geometry::IntegerSize2D
aBmpSize( xBitmap
->getSize() );
480 ENSURE_ARG_OR_THROW( aBmpSize
.Width
!= 0 &&
481 aBmpSize
.Height
!= 0,
482 "CanvasHelper::fillBitmap(): zero-sized texture bitmap" );
484 // TODO(P3): Detect case that path is rectangle and
485 // bitmap is just scaled into that. Then, we can
486 // render directly, without generating a temporary
487 // GDI+ bitmap (this is significant, because drawing
488 // layer presents background object bitmap in that
490 BitmapSharedPtr
pBitmap(
491 tools::bitmapFromXBitmap( xBitmap
) );
493 TextureBrushSharedPtr pBrush
;
494 if( ::rtl::math::approxEqual( rTexture
.Alpha
,
498 new Gdiplus::TextureBrush(
500 bClamp
? Gdiplus::WrapModeClamp
: Gdiplus::WrapModeTile
) );
504 Gdiplus::ImageAttributes aImgAttr
;
506 tools::setModulateImageAttributes( aImgAttr
,
512 Gdiplus::Rect
aRect(0,0,
516 new Gdiplus::TextureBrush(
522 bClamp
? Gdiplus::WrapModeClamp
: Gdiplus::WrapModeTile
);
525 Gdiplus::Matrix aTextureTransform
;
526 tools::gdiPlusMatrixFromAffineMatrix2D( aTextureTransform
,
527 rTexture
.AffineTransform
);
529 // scale down bitmap to [0,1]x[0,1] rect, as required
530 // from the XCanvas interface.
531 pBrush
->MultiplyTransform( &aTextureTransform
);
532 pBrush
->ScaleTransform( static_cast< Gdiplus::REAL
>(1.0/aBmpSize
.Width
),
533 static_cast< Gdiplus::REAL
>(1.0/aBmpSize
.Height
) );
535 // TODO(F1): FillRule
537 Gdiplus::Ok
== rGraphics
->FillPath( pBrush
.get(),
539 "CanvasHelper::fillTexturedPolyPolygon(): GDI+ call failed" );
545 uno::Reference
< rendering::XCachedPrimitive
> CanvasHelper::fillTexturedPolyPolygon( const rendering::XCanvas
* /*pCanvas*/,
546 const uno::Reference
< rendering::XPolyPolygon2D
>& xPolyPolygon
,
547 const rendering::ViewState
& viewState
,
548 const rendering::RenderState
& renderState
,
549 const uno::Sequence
< rendering::Texture
>& textures
)
551 ENSURE_OR_THROW( xPolyPolygon
.is(),
552 "CanvasHelper::fillTexturedPolyPolygon: polygon is NULL");
553 ENSURE_OR_THROW( textures
.getLength(),
554 "CanvasHelper::fillTexturedPolyPolygon: empty texture sequence");
558 GraphicsSharedPtr
pGraphics( mpGraphicsProvider
->getGraphics() );
560 setupGraphicsState( pGraphics
, viewState
, renderState
);
562 // TODO(F1): Multi-texturing
563 if( textures
[0].Gradient
.is() )
565 // try to cast XParametricPolyPolygon2D reference to
566 // our implementation class.
567 ::canvas::ParametricPolyPolygon
* pGradient
=
568 dynamic_cast< ::canvas::ParametricPolyPolygon
* >( textures
[0].Gradient
.get() );
572 const ::canvas::ParametricPolyPolygon::Values
& rValues(
573 pGradient
->getValues() );
575 OSL_ASSERT(rValues
.maColors
.getLength() == rValues
.maStops
.getLength()
576 && rValues
.maColors
.getLength() > 1);
578 std::vector
< Gdiplus::Color
> aColors(rValues
.maColors
.getLength());
579 std::transform(&rValues
.maColors
[0],
580 &rValues
.maColors
[0]+rValues
.maColors
.getLength(),
583 (Gdiplus::ARGB (*)( const uno::Sequence
< double >& ))(
584 &tools::sequenceToArgb
),
586 std::vector
< Gdiplus::REAL
> aStops
;
587 comphelper::sequenceToContainer(aStops
,rValues
.maStops
);
589 // TODO(E1): Return value
590 // TODO(F1): FillRule
591 fillGradient( rValues
,
595 tools::graphicsPathFromXPolyPolygon2D( xPolyPolygon
),
601 else if( textures
[0].Bitmap
.is() )
603 // TODO(E1): Return value
604 // TODO(F1): FillRule
605 fillBitmap( textures
[0].Bitmap
,
607 tools::graphicsPathFromXPolyPolygon2D( xPolyPolygon
),
612 // TODO(P1): Provide caching here.
613 return uno::Reference
< rendering::XCachedPrimitive
>(NULL
);
617 /* vim:set shiftwidth=4 softtabstop=4 expandtab: */