arklumpus/VectSharp

Letter spacing - question

Closed this issue · 3 comments

Hi,
first of all let me say how impressive the project is. I'm really surprised I haven't heard of it before.

So far I was able to easily achieve all the desired effects. The only thing I am missing now is manipulating the letter spacing.
So the question is - is there any built-in method to increase it? Or should I resort to some trick, like printing the text letter by letter, each time increasing the position by the measured size and adding a factor?

Thanks!

Hi, I'm glad you are finding the library useful!

Yes, I would say that drawing the text letter by letter is the easiest way to go in this case, see example below:

static void FillTextWithSpacing(Graphics gpr, double x, double y, string text, Font font, Brush fillColour, TextBaselines textBaseline, double scale, double increment, bool spaceOnly)
{
    if (!string.IsNullOrEmpty(text))
    {
        Font.DetailedFontMetrics metrics = font.MeasureTextAdvanced(text);

        Point baselineOrigin = new Point(x, y);

        switch (textBaseline)
        {
            case TextBaselines.Baseline:
                baselineOrigin = new Point(x - metrics.LeftSideBearing, y);
                break;
            case TextBaselines.Top:
                baselineOrigin = new Point(x - metrics.LeftSideBearing, y + metrics.Top);
                break;
            case TextBaselines.Bottom:
                baselineOrigin = new Point(x - metrics.LeftSideBearing, y + metrics.Bottom);
                break;
            case TextBaselines.Middle:
                baselineOrigin = new Point(x - metrics.LeftSideBearing, y + (metrics.Top - metrics.Bottom) * 0.5 + metrics.Bottom);
                break;
        }

        Point currentGlyphPlacementDelta = new Point();
        Point currentGlyphAdvanceDelta = new Point();
        Point nextGlyphPlacementDelta = new Point();
        Point nextGlyphAdvanceDelta = new Point();

        for (int i = 0; i < text.Length; i++)
        {
            char c = text[i];

            if (Font.EnableKerning && i < text.Length - 1)
            {
                currentGlyphPlacementDelta = nextGlyphPlacementDelta;
                currentGlyphAdvanceDelta = nextGlyphAdvanceDelta;
                nextGlyphAdvanceDelta = new Point();
                nextGlyphPlacementDelta = new Point();

                TrueTypeFile.PairKerning kerning = font.FontFamily.TrueTypeFile.Get1000EmKerning(c, text[i + 1]);

                if (kerning != null)
                {
                    currentGlyphPlacementDelta = new Point(currentGlyphPlacementDelta.X + kerning.Glyph1Placement.X, currentGlyphPlacementDelta.Y + kerning.Glyph1Placement.Y);
                    currentGlyphAdvanceDelta = new Point(currentGlyphAdvanceDelta.X + kerning.Glyph1Advance.X, currentGlyphAdvanceDelta.Y + kerning.Glyph1Advance.Y);

                    nextGlyphPlacementDelta = new Point(nextGlyphPlacementDelta.X + kerning.Glyph2Placement.X, nextGlyphPlacementDelta.Y + kerning.Glyph2Placement.Y);
                    nextGlyphAdvanceDelta = new Point(nextGlyphAdvanceDelta.X + kerning.Glyph2Advance.X, nextGlyphAdvanceDelta.Y + kerning.Glyph2Advance.Y);
                }
            }

            double lsb = font.FontFamily.TrueTypeFile.Get1000EmGlyphBearings(c).LeftSideBearing * font.FontSize / 1000;
            gpr.FillText(baselineOrigin.X + currentGlyphPlacementDelta.X + lsb, baselineOrigin.Y + currentGlyphPlacementDelta.Y, c.ToString(), font, fillColour, TextBaselines.Baseline);

            if (!spaceOnly || char.IsWhiteSpace(c))
            {
                baselineOrigin.X += (font.FontFamily.TrueTypeFile.Get1000EmGlyphWidth(c) + currentGlyphAdvanceDelta.X) * font.FontSize / 1000 * scale + increment;
                baselineOrigin.Y += currentGlyphAdvanceDelta.Y * font.FontSize / 1000;
            }
            else
            {
                baselineOrigin.X += (font.FontFamily.TrueTypeFile.Get1000EmGlyphWidth(c) + currentGlyphAdvanceDelta.X) * font.FontSize / 1000;
                baselineOrigin.Y += currentGlyphAdvanceDelta.Y * font.FontSize / 1000;
            }
        }
    }
}

// ...

Page pag = new Page(300, 195);
string text = "Lorem ipsum dolor sit amet VectSharp";
Font fnt = new Font(FontFamily.ResolveFontFamily(FontFamily.StandardFontFamilies.TimesItalic), 12);

// Normal text
pag.Graphics.FillText(10, 10, text, fnt, Colours.Black);

// Same as normal text
FillTextWithSpacing(pag.Graphics, 10, 25, text, fnt, Colours.Black, TextBaselines.Top, 1, 0, false);

// Scaled spacing
FillTextWithSpacing(pag.Graphics, 10, 40, text, fnt, Colours.Black, TextBaselines.Top, 1.1, 0, false);
FillTextWithSpacing(pag.Graphics, 10, 55, text, fnt, Colours.Black, TextBaselines.Top, 0.9, 0, false);

// Incremented spacing
FillTextWithSpacing(pag.Graphics, 10, 70, text, fnt, Colours.Black, TextBaselines.Top, 1, 2, false);

// Fixed spacing [probably only makes sense with monospaced fonts]
FillTextWithSpacing(pag.Graphics, 10, 85, text, fnt, Colours.Black, TextBaselines.Top, 0, 7, false);

// Scale + increment
FillTextWithSpacing(pag.Graphics, 10, 100, text, fnt, Colours.Black, TextBaselines.Top, 1.1, 1, false);

// Same as above, but only change the size of whitespace, rather than all letters
FillTextWithSpacing(pag.Graphics, 10, 115, text, fnt, Colours.Black, TextBaselines.Top, 1.1, 0, true);
FillTextWithSpacing(pag.Graphics, 10, 130, text, fnt, Colours.Black, TextBaselines.Top, 0.9, 0, true);
FillTextWithSpacing(pag.Graphics, 10, 145, text, fnt, Colours.Black, TextBaselines.Top, 1, 2, true);
FillTextWithSpacing(pag.Graphics, 10, 160, text, fnt, Colours.Black, TextBaselines.Top, 0, 7, true);
FillTextWithSpacing(pag.Graphics, 10, 175, text, fnt, Colours.Black, TextBaselines.Top, 1.1, 1, true);

Getting the glyph position correctly is not trivial because of kerning and bearings, but this code should work (it's adapted from the GraphicsPath.AddText method). If you need to stroke rather than fill you can simply change the FillText to StrokeText.

Here is the result:

spacing

I hope this is useful! We can leave this open for now, I'll try to include a "proper" implementation in the next version!

Hi, thank you again - that was very helpful :) I didn't realize how complex the text processing is.

I've adjusted the code slightly so it returns the size of the text, also allowing the same code to handle MeasureText / Fill / Stroke methods.

Cheers!

private static Size TextWithSpacingBase(double x, double y, string text, Font font,
    TextBaselines textBaseline, double scale, double increment, bool spaceOnly,
    Action<double,double,char>? printAction)
{
    if (string.IsNullOrEmpty(text))
    {
        return new Size(0, 0);
    }

    Font.DetailedFontMetrics metrics = font.MeasureTextAdvanced(text);

    Point baselineOrigin = new Point(x, y);

    switch (textBaseline)
    {
        case TextBaselines.Baseline:
            baselineOrigin = new Point(x - metrics.LeftSideBearing, y);
            break;
        case TextBaselines.Top:
            baselineOrigin = new Point(x - metrics.LeftSideBearing, y + metrics.Top);
            break;
        case TextBaselines.Bottom:
            baselineOrigin = new Point(x - metrics.LeftSideBearing, y + metrics.Bottom);
            break;
        case TextBaselines.Middle:
            baselineOrigin = new Point(x - metrics.LeftSideBearing,
                y + (metrics.Top - metrics.Bottom) * 0.5 + metrics.Bottom);
            break;
    }

    Point currentGlyphPlacementDelta = new Point();
    Point currentGlyphAdvanceDelta = new Point();
    Point nextGlyphPlacementDelta = new Point();
    Point nextGlyphAdvanceDelta = new Point();


    for (int i = 0; i < text.Length; i++)
    {
        char c = text[i];

        if (Font.EnableKerning && i < text.Length - 1)
        {
            currentGlyphPlacementDelta = nextGlyphPlacementDelta;
            currentGlyphAdvanceDelta = nextGlyphAdvanceDelta;
            nextGlyphAdvanceDelta = new Point();
            nextGlyphPlacementDelta = new Point();

            TrueTypeFile.PairKerning kerning = font.FontFamily.TrueTypeFile.Get1000EmKerning(c, text[i + 1]);

            if (kerning != null)
            {
                currentGlyphPlacementDelta = new Point(currentGlyphPlacementDelta.X + kerning.Glyph1Placement.X,
                    currentGlyphPlacementDelta.Y + kerning.Glyph1Placement.Y);
                currentGlyphAdvanceDelta = new Point(currentGlyphAdvanceDelta.X + kerning.Glyph1Advance.X,
                    currentGlyphAdvanceDelta.Y + kerning.Glyph1Advance.Y);

                nextGlyphPlacementDelta = new Point(nextGlyphPlacementDelta.X + kerning.Glyph2Placement.X,
                    nextGlyphPlacementDelta.Y + kerning.Glyph2Placement.Y);
                nextGlyphAdvanceDelta = new Point(nextGlyphAdvanceDelta.X + kerning.Glyph2Advance.X,
                    nextGlyphAdvanceDelta.Y + kerning.Glyph2Advance.Y);
            }
        }

        var lsb = font.FontFamily.TrueTypeFile.Get1000EmGlyphBearings(c).LeftSideBearing * font.FontSize / 1000;

        printAction?.Invoke(baselineOrigin.X + currentGlyphPlacementDelta.X + lsb,
            baselineOrigin.Y + currentGlyphPlacementDelta.Y, c);

        if (!spaceOnly || char.IsWhiteSpace(c))
        {
            baselineOrigin.X += (font.FontFamily.TrueTypeFile.Get1000EmGlyphWidth(c) + currentGlyphAdvanceDelta.X) *
                font.FontSize / 1000 * scale + increment;
            baselineOrigin.Y += currentGlyphAdvanceDelta.Y * font.FontSize / 1000;
        }
        else
        {
            baselineOrigin.X += (font.FontFamily.TrueTypeFile.Get1000EmGlyphWidth(c) + currentGlyphAdvanceDelta.X) *
                font.FontSize / 1000;
            baselineOrigin.Y += currentGlyphAdvanceDelta.Y * font.FontSize / 1000;
        }
    }

    var width = baselineOrigin.X - x - (font.FontSize / 1000 * scale + increment);
    return new Size(width, metrics.Height);
}

public static void StrokeTextWithSpacing(this Graphics gpr, double x, double y, string text, Font font, Brush brush,
    double increment,
    TextBaselines textBaseline = TextBaselines.Top, double scale = 1d, bool spaceOnly = false, double lineWidth = 1d)
{
    TextWithSpacingBase(x, y, text, font, textBaseline, scale, increment, spaceOnly, (cx, cy, c) =>
        gpr.StrokeText(cx, cy, c.ToString(), font, brush, TextBaselines.Baseline, lineWidth));
}

public static void FillTextWithSpacing(this Graphics gpr, double x, double y, string text, Font font, Brush brush,
    double increment,
    TextBaselines textBaseline = TextBaselines.Top, double scale = 1d, bool spaceOnly = false)
{
    TextWithSpacingBase(x, y, text, font, textBaseline, scale, increment, spaceOnly, (cx, cy, c) =>
        gpr.FillText(cx, cy, c.ToString(), font, brush, TextBaselines.Baseline));
}

public static Size MeasureTextWithSpacing(this Font font, string text, double increment,
    TextBaselines textBaseline = TextBaselines.Top, double scale =1d, bool spaceOnly = false)
{
    return TextWithSpacingBase(0, 0, text, font, textBaseline, scale, increment, spaceOnly, null);
}

Hi, I have added overloads for the FillText/StrokeText methods that take an optional TextSpacing parameter that specifies text spacing (like the above example, there is a scale parameter and an increment parameter). The FormattedText constructor and FormattedText.Format now also take a TextSpacing parameter.

I also added a section in the docs with an interactive example showing how these work.

I'm closing this issue for now, but feel free to reopen it (or create a new one) if you spot any problems with this or if you any other question!