619 lines
19 KiB
C++
619 lines
19 KiB
C++
/*
|
|
* This program source code file is part of KICAD, a free EDA CAD application.
|
|
*
|
|
* Copyright (C) 2021 Ola Rinta-Koski
|
|
* Copyright (C) 2021-2023 Kicad Developers, see AUTHORS.txt for contributors.
|
|
*
|
|
* Font abstract base class
|
|
*
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; either version 2
|
|
* of the License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, you may find one here:
|
|
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
|
|
* or you may search the http://www.gnu.org website for the version 2 license,
|
|
* or you may write to the Free Software Foundation, Inc.,
|
|
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
|
*/
|
|
|
|
#include <list>
|
|
#include <mutex>
|
|
#include <unordered_map>
|
|
|
|
#include <wx/font.h>
|
|
#include <string_utils.h>
|
|
#include <gal/graphics_abstraction_layer.h>
|
|
#include <font/stroke_font.h>
|
|
#include <font/outline_font.h>
|
|
#include <trigo.h>
|
|
#include <markup_parser.h>
|
|
|
|
// The "official" name of the building Kicad stroke font (always existing)
|
|
#include <font/kicad_font_name.h>
|
|
#include "macros.h"
|
|
|
|
|
|
// markup_parser.h includes pegtl.hpp which includes windows.h... which leaks #define DrawText
|
|
#undef DrawText
|
|
|
|
|
|
using namespace KIFONT;
|
|
|
|
FONT* FONT::s_defaultFont = nullptr;
|
|
|
|
std::map< std::tuple<wxString, bool, bool>, FONT*> FONT::s_fontMap;
|
|
|
|
class MARKUP_CACHE
|
|
{
|
|
public:
|
|
struct ENTRY
|
|
{
|
|
std::string source;
|
|
std::unique_ptr<MARKUP::NODE> root;
|
|
};
|
|
|
|
typedef std::pair<wxString, ENTRY> CACHE_ENTRY;
|
|
|
|
MARKUP_CACHE( size_t aMaxSize ) :
|
|
m_maxSize( aMaxSize )
|
|
{
|
|
}
|
|
|
|
ENTRY& Put( const CACHE_ENTRY::first_type& aQuery, ENTRY&& aResult )
|
|
{
|
|
auto it = m_cache.find( aQuery );
|
|
|
|
m_cacheMru.emplace_front( CACHE_ENTRY( aQuery, std::move( aResult ) ) );
|
|
|
|
if( it != m_cache.end() )
|
|
{
|
|
m_cacheMru.erase( it->second );
|
|
m_cache.erase( it );
|
|
}
|
|
|
|
m_cache[aQuery] = m_cacheMru.begin();
|
|
|
|
if( m_cache.size() > m_maxSize )
|
|
{
|
|
auto last = m_cacheMru.end();
|
|
last--;
|
|
m_cache.erase( last->first );
|
|
m_cacheMru.pop_back();
|
|
}
|
|
|
|
return m_cacheMru.begin()->second;
|
|
}
|
|
|
|
ENTRY* Get( const CACHE_ENTRY::first_type& aQuery )
|
|
{
|
|
auto it = m_cache.find( aQuery );
|
|
|
|
if( it == m_cache.end() )
|
|
return nullptr;
|
|
|
|
m_cacheMru.splice( m_cacheMru.begin(), m_cacheMru, it->second );
|
|
|
|
return &m_cacheMru.begin()->second;
|
|
}
|
|
|
|
void Clear()
|
|
{
|
|
m_cacheMru.clear();
|
|
m_cache.clear();
|
|
}
|
|
|
|
private:
|
|
size_t m_maxSize;
|
|
std::list<CACHE_ENTRY> m_cacheMru;
|
|
std::unordered_map<wxString, std::list<CACHE_ENTRY>::iterator> m_cache;
|
|
};
|
|
|
|
|
|
static MARKUP_CACHE s_markupCache( 1024 );
|
|
static std::mutex s_markupCacheMutex;
|
|
|
|
|
|
FONT::FONT()
|
|
{
|
|
}
|
|
|
|
|
|
FONT* FONT::getDefaultFont()
|
|
{
|
|
if( !s_defaultFont )
|
|
s_defaultFont = STROKE_FONT::LoadFont( wxEmptyString );
|
|
|
|
return s_defaultFont;
|
|
}
|
|
|
|
|
|
FONT* FONT::GetFont( const wxString& aFontName, bool aBold, bool aItalic )
|
|
{
|
|
if( aFontName.empty() || aFontName.StartsWith( KICAD_FONT_NAME ) )
|
|
return getDefaultFont();
|
|
|
|
std::tuple<wxString, bool, bool> key = { aFontName, aBold, aItalic };
|
|
|
|
FONT* font = s_fontMap[key];
|
|
|
|
if( !font )
|
|
font = OUTLINE_FONT::LoadFont( aFontName, aBold, aItalic );
|
|
|
|
if( !font )
|
|
font = getDefaultFont();
|
|
|
|
s_fontMap[key] = font;
|
|
|
|
return font;
|
|
}
|
|
|
|
|
|
bool FONT::IsStroke( const wxString& aFontName )
|
|
{
|
|
// This would need a more complex implementation if we ever support more stroke fonts
|
|
// than the KiCad Font.
|
|
return aFontName == _( "Default Font" ) || aFontName == KICAD_FONT_NAME;
|
|
}
|
|
|
|
|
|
void FONT::getLinePositions( const wxString& aText, const VECTOR2I& aPosition,
|
|
wxArrayString& aTextLines, std::vector<VECTOR2I>& aPositions,
|
|
std::vector<VECTOR2I>& aExtents, const TEXT_ATTRIBUTES& aAttrs ) const
|
|
{
|
|
wxStringSplit( aText, aTextLines, '\n' );
|
|
int lineCount = aTextLines.Count();
|
|
aPositions.reserve( lineCount );
|
|
|
|
int interline = GetInterline( aAttrs.m_Size.y, aAttrs.m_LineSpacing );
|
|
int height = 0;
|
|
|
|
for( int i = 0; i < lineCount; i++ )
|
|
{
|
|
VECTOR2I pos( aPosition.x, aPosition.y + i * interline );
|
|
VECTOR2I end = boundingBoxSingleLine( nullptr, aTextLines[i], pos, aAttrs.m_Size,
|
|
aAttrs.m_Italic );
|
|
VECTOR2I bBox( end - pos );
|
|
|
|
aExtents.push_back( bBox );
|
|
|
|
if( i == 0 )
|
|
height += ( aAttrs.m_Size.y * 1.17 ); // 1.17 is a fudge to match 6.0 positioning
|
|
else
|
|
height += interline;
|
|
}
|
|
|
|
VECTOR2I offset( 0, 0 );
|
|
offset.y += aAttrs.m_Size.y;
|
|
|
|
switch( aAttrs.m_Valign )
|
|
{
|
|
case GR_TEXT_V_ALIGN_TOP: break;
|
|
case GR_TEXT_V_ALIGN_CENTER: offset.y -= height / 2; break;
|
|
case GR_TEXT_V_ALIGN_BOTTOM: offset.y -= height; break;
|
|
}
|
|
|
|
for( int i = 0; i < lineCount; i++ )
|
|
{
|
|
VECTOR2I lineSize = aExtents.at( i );
|
|
VECTOR2I lineOffset( offset );
|
|
|
|
lineOffset.y += i * interline;
|
|
|
|
switch( aAttrs.m_Halign )
|
|
{
|
|
case GR_TEXT_H_ALIGN_LEFT: break;
|
|
case GR_TEXT_H_ALIGN_CENTER: lineOffset.x = -lineSize.x / 2; break;
|
|
case GR_TEXT_H_ALIGN_RIGHT: lineOffset.x = -lineSize.x; break;
|
|
}
|
|
|
|
aPositions.push_back( aPosition + lineOffset );
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Draw a string.
|
|
*
|
|
* @param aGal
|
|
* @param aText is the text to be drawn.
|
|
* @param aPosition is the text object position in world coordinates.
|
|
* @param aCursor is the current text position (for multiple text blocks within a single text
|
|
* object, such as a run of superscript characters)
|
|
* @param aAttrs are the styling attributes of the text, including its rotation
|
|
*/
|
|
void FONT::Draw( KIGFX::GAL* aGal, const wxString& aText, const VECTOR2I& aPosition,
|
|
const VECTOR2I& aCursor, const TEXT_ATTRIBUTES& aAttrs ) const
|
|
{
|
|
if( !aGal || aText.empty() )
|
|
return;
|
|
|
|
VECTOR2I position( aPosition - aCursor );
|
|
|
|
// Split multiline strings into separate ones and draw them line by line
|
|
wxArrayString strings_list;
|
|
std::vector<VECTOR2I> positions;
|
|
std::vector<VECTOR2I> extents;
|
|
|
|
getLinePositions( aText, position, strings_list, positions, extents, aAttrs );
|
|
|
|
aGal->SetLineWidth( aAttrs.m_StrokeWidth );
|
|
|
|
for( size_t i = 0; i < strings_list.GetCount(); i++ )
|
|
{
|
|
drawSingleLineText( aGal, nullptr, strings_list[i], positions[i], aAttrs.m_Size,
|
|
aAttrs.m_Angle, aAttrs.m_Mirrored, aPosition, aAttrs.m_Italic,
|
|
aAttrs.m_Underlined );
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* @return position of cursor for drawing next substring
|
|
*/
|
|
VECTOR2I drawMarkup( BOX2I* aBoundingBox, std::vector<std::unique_ptr<GLYPH>>* aGlyphs,
|
|
const MARKUP::NODE* aNode, const VECTOR2I& aPosition,
|
|
const KIFONT::FONT* aFont, const VECTOR2I& aSize, const EDA_ANGLE& aAngle,
|
|
bool aMirror, const VECTOR2I& aOrigin, TEXT_STYLE_FLAGS aTextStyle )
|
|
{
|
|
VECTOR2I nextPosition = aPosition;
|
|
bool drawUnderline = false;
|
|
bool drawOverbar = false;
|
|
|
|
if( aNode )
|
|
{
|
|
TEXT_STYLE_FLAGS textStyle = aTextStyle;
|
|
|
|
if( !aNode->is_root() )
|
|
{
|
|
if( aNode->isSubscript() )
|
|
textStyle |= TEXT_STYLE::SUBSCRIPT;
|
|
else if( aNode->isSuperscript() )
|
|
textStyle |= TEXT_STYLE::SUPERSCRIPT;
|
|
|
|
if( aNode->isOverbar() )
|
|
drawOverbar = true;
|
|
|
|
if( aNode->has_content() )
|
|
{
|
|
BOX2I bbox;
|
|
|
|
nextPosition = aFont->GetTextAsGlyphs( &bbox, aGlyphs, aNode->asWxString(), aSize,
|
|
aPosition, aAngle, aMirror, aOrigin,
|
|
textStyle );
|
|
|
|
if( aBoundingBox )
|
|
aBoundingBox->Merge( bbox );
|
|
}
|
|
}
|
|
else if( aTextStyle & TEXT_STYLE::UNDERLINE )
|
|
{
|
|
drawUnderline = true;
|
|
}
|
|
|
|
for( const std::unique_ptr<MARKUP::NODE>& child : aNode->children )
|
|
{
|
|
nextPosition = drawMarkup( aBoundingBox, aGlyphs, child.get(), nextPosition, aFont,
|
|
aSize, aAngle, aMirror, aOrigin, textStyle );
|
|
}
|
|
}
|
|
|
|
if( drawUnderline )
|
|
{
|
|
// Shorten the bar a little so its rounded ends don't make it over-long
|
|
double barTrim = aSize.x * 0.1;
|
|
double barOffset = aFont->ComputeUnderlineVerticalPosition( aSize.y );
|
|
|
|
VECTOR2D barStart( aPosition.x + barTrim, aPosition.y - barOffset );
|
|
VECTOR2D barEnd( nextPosition.x - barTrim, nextPosition.y - barOffset );
|
|
|
|
if( aGlyphs )
|
|
{
|
|
STROKE_GLYPH barGlyph;
|
|
|
|
barGlyph.AddPoint( barStart );
|
|
barGlyph.AddPoint( barEnd );
|
|
barGlyph.Finalize();
|
|
|
|
aGlyphs->push_back( barGlyph.Transform( { 1.0, 1.0 }, { 0, 0 }, false, aAngle, aMirror,
|
|
aOrigin ) );
|
|
}
|
|
}
|
|
|
|
if( drawOverbar )
|
|
{
|
|
// Shorten the bar a little so its rounded ends don't make it over-long
|
|
double barTrim = aSize.x * 0.1;
|
|
double barOffset = aFont->ComputeOverbarVerticalPosition( aSize.y );
|
|
|
|
VECTOR2D barStart( aPosition.x + barTrim, aPosition.y - barOffset );
|
|
VECTOR2D barEnd( nextPosition.x - barTrim, nextPosition.y - barOffset );
|
|
|
|
if( aGlyphs )
|
|
{
|
|
STROKE_GLYPH barGlyph;
|
|
|
|
barGlyph.AddPoint( barStart );
|
|
barGlyph.AddPoint( barEnd );
|
|
barGlyph.Finalize();
|
|
|
|
aGlyphs->push_back( barGlyph.Transform( { 1.0, 1.0 }, { 0, 0 }, false, aAngle, aMirror,
|
|
aOrigin ) );
|
|
}
|
|
}
|
|
|
|
return nextPosition;
|
|
}
|
|
|
|
|
|
VECTOR2I FONT::drawMarkup( BOX2I* aBoundingBox, std::vector<std::unique_ptr<GLYPH>>* aGlyphs,
|
|
const wxString& aText, const VECTOR2I& aPosition, const VECTOR2I& aSize,
|
|
const EDA_ANGLE& aAngle, bool aMirror, const VECTOR2I& aOrigin,
|
|
TEXT_STYLE_FLAGS aTextStyle ) const
|
|
{
|
|
std::lock_guard<std::mutex> lock( s_markupCacheMutex );
|
|
|
|
MARKUP_CACHE::ENTRY* markup = s_markupCache.Get( aText );
|
|
|
|
if( !markup || !markup->root )
|
|
{
|
|
MARKUP_CACHE::ENTRY& cached = s_markupCache.Put( aText, {} );
|
|
|
|
cached.source = TO_UTF8( aText );
|
|
MARKUP::MARKUP_PARSER markupParser( &cached.source );
|
|
cached.root = markupParser.Parse();
|
|
markup = &cached;
|
|
}
|
|
|
|
wxASSERT( markup && markup->root );
|
|
|
|
return ::drawMarkup( aBoundingBox, aGlyphs, markup->root.get(), aPosition, this, aSize, aAngle,
|
|
aMirror, aOrigin, aTextStyle );
|
|
}
|
|
|
|
|
|
void FONT::drawSingleLineText( KIGFX::GAL* aGal, BOX2I* aBoundingBox, const wxString& aText,
|
|
const VECTOR2I& aPosition, const VECTOR2I& aSize,
|
|
const EDA_ANGLE& aAngle, bool aMirror, const VECTOR2I& aOrigin,
|
|
bool aItalic, bool aUnderline ) const
|
|
{
|
|
if( !aGal )
|
|
return;
|
|
|
|
TEXT_STYLE_FLAGS textStyle = 0;
|
|
|
|
if( aItalic )
|
|
textStyle |= TEXT_STYLE::ITALIC;
|
|
|
|
if( aUnderline )
|
|
textStyle |= TEXT_STYLE::UNDERLINE;
|
|
|
|
std::vector<std::unique_ptr<GLYPH>> glyphs;
|
|
|
|
(void) drawMarkup( aBoundingBox, &glyphs, aText, aPosition, aSize, aAngle, aMirror, aOrigin,
|
|
textStyle );
|
|
|
|
aGal->DrawGlyphs( glyphs );
|
|
}
|
|
|
|
|
|
VECTOR2I FONT::StringBoundaryLimits( const wxString& aText, const VECTOR2I& aSize, int aThickness,
|
|
bool aBold, bool aItalic ) const
|
|
{
|
|
// TODO do we need to parse every time - have we already parsed?
|
|
BOX2I boundingBox;
|
|
TEXT_STYLE_FLAGS textStyle = 0;
|
|
|
|
if( aBold )
|
|
textStyle |= TEXT_STYLE::BOLD;
|
|
|
|
if( aItalic )
|
|
textStyle |= TEXT_STYLE::ITALIC;
|
|
|
|
(void) drawMarkup( &boundingBox, nullptr, aText, VECTOR2I(), aSize, ANGLE_0, false,
|
|
VECTOR2I(), textStyle );
|
|
|
|
if( IsStroke() )
|
|
{
|
|
// Inflate by a bit more than thickness/2 to catch diacriticals, descenders, etc.
|
|
boundingBox.Inflate( KiROUND( aThickness * 1.25 ) );
|
|
}
|
|
else if( IsOutline() )
|
|
{
|
|
// Outline fonts have thickness built in, and *usually* stay within their ascent/descent
|
|
}
|
|
|
|
return boundingBox.GetSize();
|
|
}
|
|
|
|
|
|
VECTOR2I FONT::boundingBoxSingleLine( BOX2I* aBBox, const wxString& aText,
|
|
const VECTOR2I& aPosition, const VECTOR2I& aSize,
|
|
bool aItalic ) const
|
|
{
|
|
TEXT_STYLE_FLAGS textStyle = 0;
|
|
|
|
if( aItalic )
|
|
textStyle |= TEXT_STYLE::ITALIC;
|
|
|
|
VECTOR2I extents = drawMarkup( aBBox, nullptr, aText, aPosition, aSize, ANGLE_0, false,
|
|
VECTOR2I(), textStyle );
|
|
|
|
return extents;
|
|
}
|
|
|
|
|
|
/*
|
|
* Break marked-up text into "words". In this context, a "word" is EITHER a run of marked-up
|
|
* text (subscript, superscript or overbar), OR a run of non-marked-up text separated by spaces.
|
|
*/
|
|
void wordbreakMarkup( std::vector<std::pair<wxString, int>>* aWords,
|
|
const std::unique_ptr<MARKUP::NODE>& aNode, const KIFONT::FONT* aFont,
|
|
const VECTOR2I& aSize, TEXT_STYLE_FLAGS aTextStyle )
|
|
{
|
|
TEXT_STYLE_FLAGS textStyle = aTextStyle;
|
|
|
|
if( !aNode->is_root() )
|
|
{
|
|
wxChar escapeChar = 0;
|
|
|
|
if( aNode->isSubscript() )
|
|
{
|
|
escapeChar = '_';
|
|
textStyle = TEXT_STYLE::SUBSCRIPT;
|
|
}
|
|
else if( aNode->isSuperscript() )
|
|
{
|
|
escapeChar = '^';
|
|
textStyle = TEXT_STYLE::SUPERSCRIPT;
|
|
}
|
|
|
|
if( aNode->isOverbar() )
|
|
{
|
|
escapeChar = '~';
|
|
textStyle |= TEXT_STYLE::OVERBAR;
|
|
}
|
|
|
|
if( escapeChar )
|
|
{
|
|
wxString word = wxString::Format( wxT( "%c{" ), escapeChar );
|
|
int width = 0;
|
|
|
|
if( aNode->has_content() )
|
|
{
|
|
VECTOR2I next = aFont->GetTextAsGlyphs( nullptr, nullptr, aNode->asWxString(),
|
|
aSize, { 0, 0 }, ANGLE_0, false, { 0, 0 },
|
|
textStyle );
|
|
word += aNode->asWxString();
|
|
width += next.x;
|
|
}
|
|
|
|
std::vector<std::pair<wxString, int>> childWords;
|
|
|
|
for( const std::unique_ptr<MARKUP::NODE>& child : aNode->children )
|
|
wordbreakMarkup( &childWords, child, aFont, aSize, textStyle );
|
|
|
|
for( const std::pair<wxString, int>& childWord : childWords )
|
|
{
|
|
word += childWord.first;
|
|
width += childWord.second;
|
|
}
|
|
|
|
word += wxT( "}" );
|
|
aWords->emplace_back( std::make_pair( word, width ) );
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
wxString textRun = aNode->asWxString();
|
|
wxArrayString words;
|
|
|
|
wxStringSplit( textRun, words, ' ' );
|
|
|
|
if( textRun.EndsWith( wxS( " " ) ) )
|
|
words.Add( wxS( " " ) );
|
|
|
|
for( size_t ii = 0; ii < words.size(); ++ii )
|
|
{
|
|
int w = aFont->GetTextAsGlyphs( nullptr, nullptr, words[ii], aSize, { 0, 0 },
|
|
ANGLE_0, false, { 0, 0 }, textStyle ).x;
|
|
|
|
aWords->emplace_back( std::make_pair( words[ii], w ) );
|
|
}
|
|
}
|
|
}
|
|
|
|
for( const std::unique_ptr<MARKUP::NODE>& child : aNode->children )
|
|
wordbreakMarkup( aWords, child, aFont, aSize, textStyle );
|
|
}
|
|
|
|
|
|
void FONT::wordbreakMarkup( std::vector<std::pair<wxString, int>>* aWords, const wxString& aText,
|
|
const VECTOR2I& aSize, TEXT_STYLE_FLAGS aTextStyle ) const
|
|
{
|
|
MARKUP::MARKUP_PARSER markupParser( TO_UTF8( aText ) );
|
|
std::unique_ptr<MARKUP::NODE> root = markupParser.Parse();
|
|
|
|
::wordbreakMarkup( aWords, root, this, aSize, aTextStyle );
|
|
}
|
|
|
|
|
|
/*
|
|
* This is a highly simplified line-breaker. KiCad is an EDA tool, not a word processor.
|
|
*
|
|
* 1) It breaks only on spaces. If you type a word wider than the column width then you get
|
|
* overflow.
|
|
* 2) It treats runs of formatted text (superscript, subscript, overbar) as single words.
|
|
* 3) It does not perform justification.
|
|
*
|
|
* The results of the linebreaking are the addition of \n in the text. It is presumed that this
|
|
* function is called on m_shownText (or equivalent) rather than the original source text.
|
|
*/
|
|
void FONT::LinebreakText( wxString& aText, int aColumnWidth, const VECTOR2I& aSize, int aThickness,
|
|
bool aBold, bool aItalic ) const
|
|
{
|
|
TEXT_STYLE_FLAGS textStyle = 0;
|
|
|
|
if( aBold )
|
|
textStyle |= TEXT_STYLE::BOLD;
|
|
|
|
if( aItalic )
|
|
textStyle |= TEXT_STYLE::ITALIC;
|
|
|
|
int spaceWidth = GetTextAsGlyphs( nullptr, nullptr, wxS( " " ), aSize, VECTOR2I(), ANGLE_0,
|
|
false, VECTOR2I(), textStyle ).x;
|
|
|
|
wxArrayString textLines;
|
|
wxStringSplit( aText, textLines, '\n' );
|
|
|
|
aText = wxEmptyString;
|
|
|
|
for( size_t ii = 0; ii < textLines.Count(); ++ii )
|
|
{
|
|
bool virginLine = true;
|
|
int lineWidth = 0;
|
|
std::vector<std::pair<wxString, int>> words;
|
|
|
|
wordbreakMarkup( &words, textLines[ii], aSize, textStyle );
|
|
|
|
for( size_t jj = 0; jj < words.size(); /* advance in loop */ )
|
|
{
|
|
if( virginLine )
|
|
{
|
|
// First word is always placed, even when wider than columnWidth.
|
|
aText += words[jj].first;
|
|
lineWidth += words[jj].second;
|
|
jj++;
|
|
|
|
virginLine = false;
|
|
}
|
|
else if( lineWidth + spaceWidth + words[jj].second < aColumnWidth - aThickness )
|
|
{
|
|
aText += " " + words[jj].first;
|
|
lineWidth += spaceWidth + words[jj].second;
|
|
jj++;
|
|
}
|
|
else
|
|
{
|
|
aText += '\n';
|
|
|
|
lineWidth = 0;
|
|
virginLine = true;
|
|
}
|
|
}
|
|
|
|
// Add the newlines back onto the string
|
|
if( ii != ( textLines.Count() - 1 ) )
|
|
aText += '\n';
|
|
}
|
|
}
|
|
|
|
|