Skip to content

Chapter 7: Text Layout

Goal of this chapter: master the WhatsCanvas text system — font registration and fallback, single- and multi-line text, text measurement, CJK/RTL layout, and text on path.

For the Chinese version, see zh/07-text-bindlayout.md.


7.1 Text System Overview

The WhatsCanvas text system does much more than "draw characters":

  • Font discovery: automatic system font discovery (Windows / Linux / macOS)
  • Fallback chain: falls back to alternative fonts when the primary font is missing a glyph
  • HarfBuzz shaping: correct shaping of complex scripts (Arabic, Devanagari, ...)
  • UAX #9 bidi: mixed RTL and LTR text
  • CJK layout: no-space line breaking, punctuation kinsoku
  • Color emoji: COLR/CPAL v0 and common COLRv1

7.2 Font Registration

Use the Built-In Default Fonts

WhatsCanvas ships a built-in fallback font set for scenarios without external fonts:

#include <wsc/FontSystem.h>

// Register the default system fonts
for (const auto& face : wsc::FontSystem::defaultSystemFontFaces()) {
    canvas->registerFontFace(face);
}

// Set the fallback chain
canvas->setFontFallbackChain(wsc::FontSystem::defaultFallbackChain());

Register a Custom Font from a File

wsc::FontDescriptor desc("MyFont", 400, wsc::FontSlant::NORMAL);
wsc::FontFace face = wsc::FontFace::fromFile(desc, "/path/to/MyFont-Regular.ttf");
canvas->registerFontFace(face);

// Register the bold variant
wsc::FontDescriptor boldDesc("MyFont", 700, wsc::FontSlant::NORMAL);
wsc::FontFace boldFace = wsc::FontFace::fromFile(boldDesc, "/path/to/MyFont-Bold.ttf");
canvas->registerFontFace(boldFace);

Register from Memory

std::vector<uint8_t> fontData = loadFontFromResource();
wsc::FontDescriptor desc("EmbeddedFont");
wsc::FontFace face = wsc::FontFace::fromMemory(desc, std::move(fontData));
canvas->registerFontFace(face);

Font with a Codepoint Range

When you want a font to handle only specific Unicode ranges:

wsc::FontFace emojiFace = wsc::FontFace::fromFile(
    wsc::FontDescriptor("Emoji"), "/path/to/NotoColorEmoji.ttf");
emojiFace.addCodepointRange(0x1F600, 0x1F64F);  // Emoticons
emojiFace.addCodepointRange(0x1F300, 0x1F5FF);  // Symbols
canvas->registerFontFace(emojiFace);

7.3 Native Platform Text Backends

WhatsCanvas can use the native platform text engine for better system consistency:

// Windows: use DirectWrite
canvas->setTextBackend(wsc::Canvas::TextBackend::DirectWrite);

// macOS / iOS: use CoreText
canvas->setTextBackend(wsc::Canvas::TextBackend::CoreText);

// Cross-platform default: FreeType + HarfBuzz
canvas->setTextBackend(wsc::Canvas::TextBackend::Portable);

7.4 Single-Line Text

Font Size Uses Logical Units, Not Platform sp

Paint::setTextSize takes Canvas logical units. It does not consult Android density, user font size, or the Web CSS pipeline, so you cannot call the value "px", "dp", or "sp" without accounting for DPR.

If a 720-pixel-wide Canvas uses DPR 2, the logical width is 360. Then setTextSize(16) lays out with 16 logical units and rasterizes at about 32 physical pixels, which is close to the visual baseline of Android 16 sp under default font settings. Without a DPR, the same 16 produces only about 16 physical pixels.

Android also needs to respect the user's font size. The host should compute the pixel size via TypedValue.applyDimension(COMPLEX_UNIT_SP, value, metrics) and then divide by the density it passes to the Canvas:

Canvas logical text size = Android converted text pixels / Canvas DPR

Do not use bold weight to compensate for a wrong text size or DPR. Body text usually starts at weight 400; only reach for 500 or 600 when the design genuinely calls for emphasis.

Basic Drawing

wsc::Paint textPaint;
textPaint.setColor(wsc::Color(33, 33, 33, 255));
textPaint.setTextSize(24.0f);
textPaint.setFontFamily("Arial");

// drawText(text, x, y, paint)
// (x, y) is the start of the text baseline
canvas->drawText("Hello, WhatsCanvas!", 50, 100, textPaint);

Text Alignment

float centerX = 200;

wsc::Paint left;
left.setTextSize(20.0f);
left.setTextAlign(wsc::Paint::TextAlign::LEFT);    // Default
canvas->drawText("Left aligned", centerX, 60, left);

wsc::Paint center;
center.setTextSize(20.0f);
center.setTextAlign(wsc::Paint::TextAlign::CENTER);
canvas->drawText("Center aligned", centerX, 100, center);

wsc::Paint right;
right.setTextSize(20.0f);
right.setTextAlign(wsc::Paint::TextAlign::RIGHT);
canvas->drawText("Right aligned", centerX, 140, right);

Font Style

wsc::Paint paint;
paint.setTextSize(20.0f);

// Family
paint.setFontFamily("Roboto");

// Weight (100–1000, 400=normal, 700=bold)
paint.setFontWeight(700);

// Italic
paint.setFontSlant(wsc::FontSlant::ITALIC);

// Letter spacing
paint.setLetterSpacing(2.0f);

// Underline
paint.setUnderline(true);

// Strikethrough
paint.setStrikethrough(true);

OpenType Features

// Enable ligatures
paint.setFontFeature("liga", 1);

// Disable ligatures
paint.setFontFeature("liga", 0);

// Tabular numbers
paint.setFontFeature("tnum", 1);

// Oldstyle numbers
paint.setFontFeature("onum", 1);

7.5 Text Measurement

Width

float width = canvas->measureText("Hello World", textPaint);

Full Metrics

wsc::Canvas::TextMetrics metrics = canvas->measureTextMetrics("Hello", textPaint);

// metrics.width      — Text width
// metrics.height     — Line height
// metrics.ascent     — Height above baseline (negative)
// metrics.descent    — Depth below baseline
// metrics.lineGap    — Recommended line gap
// metrics.lineHeight — Recommended line height (ascent + descent + lineGap)
// metrics.bounds     — Ink bounding box (RectF)

Bounding Box

wsc::RectF bounds = canvas->measureTextBounds("Hello", textPaint);
// bounds is the exact pixel bounding box

7.6 Multi-Line Text

Basic Multi-Line

wsc::Paint paint;
paint.setTextSize(16.0f);
paint.setColor(wsc::Color(33, 33, 33, 255));

// drawTextBox(text, bounds, paint)
// Text is wrapped automatically inside `bounds`
canvas->drawTextBox(
    u8"WhatsCanvas supports multi-line text. Text is wrapped automatically inside "
    u8"the specified rectangle and respects rules such as CJK no-space wrapping and "
    u8"punctuation kinsoku.",
    wsc::RectF(50, 50, 300, 200),
    paint
);

Explicit Line Height

// drawTextBox(text, bounds, lineHeight, paint)
canvas->drawTextBox(
    u8"This paragraph uses 1.8x line height.",
    wsc::RectF(50, 50, 300, 200),
    1.8f,   // Line-height multiplier
    paint
);

Max Lines + Ellipsis

// drawTextBox(text, bounds, lineHeight, maxLines, ellipsize, paint)
canvas->drawTextBox(
    u8"This is a long paragraph that will be truncated with an ellipsis after the "
    u8"maximum number of lines. WhatsCanvas computes the break position so the "
    u8"ellipsis lands where it should.",
    wsc::RectF(50, 50, 250, 100),
    1.5f,    // Line height
    3,       // Max 3 lines
    true,    // Enable ellipsis
    paint
);

Retrieve Layout Result

You can obtain the per-line layout details for custom rendering or hit-testing:

auto lines = canvas->layoutTextBox(
    u8"First line.\nSecond line that wraps around.",
    wsc::RectF(0, 0, 200, 400),
    1.5f, 0, false, paint
);

for (const auto& line : lines) {
    // line.text         — Text of this line
    // line.x, line.y    — Drawing coordinates
    // line.width        — Line width
    // line.lineHeight   — Line height
    // line.ellipsized   — Whether the line was truncated with an ellipsis
    // line.sourceStart  — Offset in the original text
    // line.sourceLength — Number of characters from the source
}

7.7 Text Effects

Outlined Text

wsc::Paint strokeText;
strokeText.setTextSize(48.0f);
strokeText.setStyle(wsc::Paint::Style::STROKE);
strokeText.setStrokeWidth(2.0f);
strokeText.setColor(wsc::Color(33, 33, 33, 255));
canvas->drawText("Outline", 50, 100, strokeText);

Gradient Text

wsc::Paint gradText;
gradText.setTextSize(48.0f);
gradText.setLinearGradient(50, 0, 350, 0,
    wsc::Color(255, 0, 128, 255), wsc::Color(0, 128, 255, 255));
canvas->drawText("Gradient Text", 50, 100, gradText);

Shadow Text

wsc::Paint shadowText;
shadowText.setTextSize(36.0f);
shadowText.setColor(wsc::Color(255, 255, 255, 255));
shadowText.setShadowLayer(6.0f, 2.0f, 3.0f, wsc::Color(0, 0, 0, 150));
canvas->drawText("Shadow Text", 50, 100, shadowText);

Glyph coverage-mask blur (since 1.2.0)

Available since WhatsCanvas 1.2.0. setTextMaskBlur(radius) softens each glyph's coverage mask before applying the text color. The mask records opacity rather than final RGB values. It can be reused when the same glyph, font settings and blur radius are drawn with another color or at another position.

For a soft text shadow, draw an offset blurred copy followed by sharp text:

wsc::Paint text;
text.setTextSize(48.0f);
text.setTextMaskBlur(3.0f);
text.setColor(wsc::Color(0, 0, 0, 120));
canvas->drawText("Soft text", 42, 82, text);

text.setTextMaskBlur(0.0f);
text.setColor(wsc::Color(40, 100, 230, 255));
canvas->drawText("Soft text", 40, 80, text);

The second draw is explicit: mask blur alone does not add a sharp foreground or a shadow offset. Its default is zero; negative and non-finite values disable it. The radius uses logical text units and scales with text. Layout width is unchanged, but the visible halo can extend outside the unblurred bounds.

The portable alpha-glyph path caches blurred masks; its raster blur radius is rounded to a whole pixel. Large radii, color glyphs and other unsupported combinations use a filtered-layer fallback. Per-glyph blur can differ from blurring an already-composited text run, especially where glyphs overlap. Keep saveLayer with an image filter when whole-layer blur is the intended effect.


7.8 Text on Path

Draw text along a path:

// Create an arc path
wsc::Path arc;
arc.addCircle(200, 200, 120);  // Draw along a circle

wsc::Paint textPaint;
textPaint.setTextSize(18.0f);
textPaint.setColor(wsc::Color(66, 133, 244, 255));

// drawTextOnPath(text, path, hOffset, vOffset, paint)
// hOffset: starting offset along the path
// vOffset: offset perpendicular to the path (positive = outward)
canvas->drawTextOnPath(u8"Text flowing along a circular path", arc, 0, 0, textPaint);

Wavy Text

wsc::Path wave;
wave.moveTo(0, 150);
wave.cubicTo(100, 50, 200, 250, 300, 150);
wave.cubicTo(400, 50, 500, 250, 600, 150);

canvas->drawTextOnPath("Wavy text on a cubic bezier curve", wave, 0, -10, textPaint);

7.9 CJK and Multilingual Layout

WhatsCanvas natively supports CJK text layout:

wsc::Paint paint;
paint.setTextSize(16.0f);
paint.setFontFamily(wsc::FontSystem::kDefaultCjkFamily);

// CJK text breaks correctly between characters (no space required)
canvas->drawTextBox(
    u8"CJK text layout support: WhatsCanvas handles line breaks for Chinese, 日本語, and 한국어 correctly, "
    u8"and respects punctuation kinsoku rules. Brackets (like this) do not start a line.",
    wsc::RectF(50, 50, 280, 300),
    1.6f, 0, false, paint
);

RTL Text (Arabic / Hebrew)

wsc::Paint rtlPaint;
rtlPaint.setTextSize(20.0f);
rtlPaint.setFontFamily(wsc::FontSystem::kDefaultArabicFamily);
rtlPaint.setTextAlign(wsc::Paint::TextAlign::RIGHT);

// UAX #9 bidi algorithm handles direction automatically
canvas->drawText(u8"مرحبا بالعالم", 350, 100, rtlPaint);

// Mixed-direction text works too
canvas->drawText(u8"Hello مرحبا World عالم!", 350, 140, rtlPaint);

7.10 Font Fallback Chain

The fallback chain determines which alternative fonts are tried when the primary font is missing a glyph:

// Build a custom fallback chain
wsc::FontFallbackChain chain("Roboto");
chain.addFallbackFamily("Noto Sans SC");       // Chinese fallback
chain.addFallbackFamily("Noto Sans JP");       // Japanese fallback
chain.addFallbackFamily("Noto Sans Arabic");   // Arabic fallback
chain.addFallbackFamily("Noto Color Emoji");   // Emoji fallback

canvas->setFontFallbackChain(chain);

Use the default fallback chain (recommended):

canvas->setFontFallbackChain(wsc::FontSystem::defaultFallbackChain());

7.11 System Font Discovery

On desktop platforms you can enumerate installed fonts:

// Discover all installed system font faces
auto faces = wsc::FontSystem::discoverInstalledFontFaces();

for (const auto& face : faces) {
    printf("Family: %s, Weight: %d, Slant: %d\n",
        face.family().c_str(),
        face.weight(),
        static_cast<int>(face.slant()));
}

7.12 Integrated Example: Chat Bubbles

Chat UI featuring Chinese, Arabic, emoji, and auto-wrapping in one view

The key point: the example outputs 720 × 820 physical pixels; with DPR 2 the drawing code uses a 360 × 410 logical coordinate space. Body text uses 16 logical units at weight 400. Chinese uses the system font fallback, Arabic is laid out RTL, and long messages are constrained by drawTextBox. The code below matches the compilable source that produced the picture.

// Chapter 07 comprehensive example: multilingual chat bubbles and wrapping.
#include <wsc/FontSystem.h>
#include <wsc/wsc.h>

int main()
{
    // 1. Output 720x820 physical pixels but use a 360x410 logical layout.
    // DPR only decides how logical units map to physical pixels; no need to scale coordinates manually.
    constexpr int kPhysicalWidth = 720;
    constexpr int kPhysicalHeight = 820;
    constexpr float kDpr = 2.0f;
    constexpr float kLogicalWidth = kPhysicalWidth / kDpr;
    constexpr float kLogicalHeight = kPhysicalHeight / kDpr;

    auto canvas = wsc::Canvas::create(
        wsc::Canvas::Backend::Software, kPhysicalWidth, kPhysicalHeight);
    if (!canvas) return 1;
    canvas->setDevicePixelRatio(kDpr);
    if (!canvas->initializeContext()) return 1;
    for (const auto &face : wsc::FontSystem::defaultSystemFontFaces()) {
        canvas->registerFontFace(face);
    }
    canvas->setFontFallbackChain(wsc::FontSystem::defaultFallbackChain());

    // 2. Draw the chat page background and title bar.
    canvas->beginFrame();
    wsc::Paint background;
    background.setColor(wsc::Color(239, 243, 249, 255));
    canvas->drawRect(wsc::RectF(0, 0, kLogicalWidth, kLogicalHeight), background);

    wsc::Paint header;
    header.setLinearGradient(0, 0, kLogicalWidth, 58,
        wsc::Color(82, 116, 242, 255), wsc::Color(93, 86, 216, 255));
    canvas->drawRect(wsc::RectF(0, 0, kLogicalWidth, 58), header);

    wsc::Paint title;
    title.setColor(wsc::Color(255, 255, 255, 255));
    title.setTextSize(20.0f);
    title.setFontWeight(650);
    title.setTextAlign(wsc::Paint::TextAlign::CENTER);
    title.setTextBaseline(wsc::Paint::TextBaseline::MIDDLE);
    canvas->drawText("WhatsCanvas Chat", kLogicalWidth / 2, 22, title);
    title.setTextSize(10.5f);
    title.setFontWeight(400);
    title.setColor(wsc::Color(235, 239, 255, 210));
    canvas->drawText("CJK · RTL · Emoji · Auto wrapping", kLogicalWidth / 2, 42, title);

    // 3. The bubble helper handles side-of-screen layout, shadow, RTL, and auto wrapping.
    auto drawBubble = [&](const char *text, float y, float width, bool isMe,
                          bool rtl = false, bool multiline = false) {
        const float height = multiline ? 58.0f : 44.0f;
        const float x = isMe ? kLogicalWidth - 20.0f - width : 20.0f;
        const wsc::RectF bounds(x, y, width, height);
        canvas->drawBoxShadow(bounds, 18, 0, 5, 0, 2, wsc::Color(33, 48, 78, 28));
        wsc::Paint bubble;
        bubble.setColor(isMe ? wsc::Color(82, 116, 242, 255) : wsc::Color(255, 255, 255, 255));
        bubble.setAntiAlias(true);
        canvas->drawRoundRect(bounds, 18, bubble);

        wsc::Paint textPaint;
        textPaint.setColor(isMe ? wsc::Color(255, 255, 255, 255) : wsc::Color(35, 43, 60, 255));
        // 16 logical units rasterize to about 32 physical pixels at DPR=2.
        // Explicitly use the body Regular weight; do not fake a small size with bold weight.
        textPaint.setTextSize(16.0f);
        textPaint.setFontWeight(400);
        textPaint.setTextBaseline(wsc::Paint::TextBaseline::MIDDLE);
        textPaint.setTextLocale(rtl ? "ar" : "zh-CN");
        if (rtl) {
            textPaint.setTextAlign(wsc::Paint::TextAlign::RIGHT);
            canvas->drawText(text, x + width - 16, y + height / 2, textPaint);
        } else if (multiline) {
            textPaint.setTextBaseline(wsc::Paint::TextBaseline::TOP);
            canvas->drawTextBox(text, wsc::RectF(x + 16, y + 8, width - 32, height - 12),
                                22.0f, 2, true, textPaint);
        } else {
            canvas->drawText(text, x + 16, y + height / 2, textPaint);
        }
    };

    // 4. Use Chinese, Arabic, and emoji to verify font fallback and shaping.
    drawBubble(u8"你好,欢迎体验 WhatsCanvas。", 72, 235, false);
    drawBubble(u8"文字排版看起来很顺滑。", 122, 215, true);
    drawBubble(u8"مرحبا! يدعم النص من اليمين.", 172, 240, false, true);
    drawBubble(u8"Emoji fallback works ✨ 🎨", 222, 220, true);
    drawBubble(u8"Long text wraps automatically; beyond the max line count it ends with an ellipsis.",
               272, 265, false, false, true);

    // 5. Draw the bottom input field and send button.
    const wsc::RectF input(20, 346, 320, 48);
    canvas->drawBoxShadow(input, 24, 0, 6, 0, 2, wsc::Color(33, 48, 78, 24));
    wsc::Paint inputPaint;
    inputPaint.setColor(wsc::Color(255, 255, 255, 255));
    inputPaint.setAntiAlias(true);
    canvas->drawRoundRect(input, 24, inputPaint);
    wsc::Paint placeholder;
    placeholder.setColor(wsc::Color(145, 155, 177, 255));
    placeholder.setTextSize(15.0f);
    placeholder.setFontWeight(400);
    placeholder.setTextBaseline(wsc::Paint::TextBaseline::MIDDLE);
    canvas->drawText(u8"Type a message…", 34, 370, placeholder);
    inputPaint.setColor(wsc::Color(82, 116, 242, 255));
    canvas->drawCircle(320, 370, 16, inputPaint);

    // 6. Submit and save the multilingual chat picture.
    canvas->endFrame();
    return canvas->savePixelsPPM("chapter07_chat.ppm") ? 0 : 2;
}

7.13 Summary

This chapter covered:

  • [x] Font registration (file / memory / system discovery)
  • [x] Fallback chain configuration
  • [x] Native platform text backends (DirectWrite / CoreText)
  • [x] Single-line text and alignment
  • [x] Font style (weight / slant / spacing / features)
  • [x] Text measurement (width / metrics / bounds)
  • [x] Multi-line text and ellipsis
  • [x] Text effects (outline / gradient / shadow)
  • [x] Text on path
  • [x] CJK and RTL multilingual layout

Next chapter: Layer Filters and Effects — advanced visual effects: blur, inner shadow, frosted glass.