API Unavailable
This demo requires Chrome Canary with the
canvas-draw-element flag enabled.
RTL
Arabic & Hebrew
ctx.fillText()
No bidi algorithm — glyphs render left-to-right, punctuation
misplaced.
drawElementImage()
Browser's bidi algorithm handles direction, punctuation, and
shaping.
Vertical
CJK (Chinese / Japanese / Korean)
ctx.fillText()
No writing-mode support — text forced horizontal.
drawElementImage()
CSS
writing-mode: vertical-rl works natively.
Complex
Thai Script with Stacking Diacritics
ctx.fillText()
Stacking marks may misalign — platform-dependent shaping.
drawElementImage()
Browser's text shaping engine handles all mark
positioning.
Mixed
Bidirectional Text with Numbers
ctx.fillText()
fillText renders a single direction — mixed runs break.
drawElementImage()
Unicode bidi algorithm resolves each embedded run
correctly.
Emoji
Skin Tones & ZWJ Sequences
ctx.fillText()
ZWJ sequences may split into separate glyphs.
drawElementImage()
Browser's emoji engine composes ZWJ sequences and skin
tones.
Ruby
Japanese Furigana Annotations
ctx.fillText()
No ruby support — annotations drawn inline, breaking
flow.
drawElementImage()
HTML <ruby> and <rt> render with proper
positioning.
Side-by-side comparison of ctx.fillText() vs drawElementImage for complex scripts — RTL, vertical CJK, Thai diacritics, mixed-direction, emoji with skin tones, and ruby annotations.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Internationalized Text — HTML-in-Canvas</title>
<style>
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body,
:host {
display: block;
margin: 0;
min-height: 100%;
background: #0a0a0f;
color: #f0f0f0;
font-family: system-ui, -apple-system, sans-serif;
padding: 1.5rem;
overflow-x: hidden;
}
/* ==========================================================
Script examples — the six i18n test cases
========================================================== */
.examples {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.example {
background: #14141f;
border-radius: 12px;
border: 1px solid #282840;
overflow: hidden;
}
.example-header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #282840;
display: flex;
align-items: center;
gap: 0.5rem;
}
.example-header .script-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6c41f0;
}
.example-header .script-name {
font-size: 0.85rem;
font-weight: 500;
color: #f0f0f0;
}
.example-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.side {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.side:first-child {
border-right: 1px solid #282840;
}
.side-label {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.side.filltext .side-label {
color: #d52e66;
}
.side.html .side-label {
color: #00e5b9;
}
canvas {
width: 100%;
aspect-ratio: 16 / 9;
display: block;
border-radius: 6px;
background: #1a1a2a;
overflow: hidden;
}
.side-note {
font-size: 0.68rem;
color: #5a5a70;
line-height: 1.4;
font-style: italic;
}
/* ==========================================================
Text content inside the HTML-in-Canvas canvases.
These elements are styled with CSS and rendered
pixel-perfectly by drawElementImage().
========================================================== */
.i18n-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
line-height: 1.5;
overflow: hidden;
}
.i18n-content.rtl-text {
direction: rtl;
font-size: 17px;
font-family: "Segoe UI", "Tahoma", "Arial", system-ui, sans-serif;
text-align: right;
}
.i18n-content.vertical-cjk {
writing-mode: vertical-rl;
font-size: 17px;
font-family: "Noto Sans CJK", "Hiragino Sans", "Yu Gothic",
"MS Gothic", system-ui, sans-serif;
text-align: start;
letter-spacing: 0.05em;
}
.i18n-content.thai-text {
font-size: 18px;
font-family: "Noto Sans Thai", "Leelawadee UI", "Tahoma", system-ui,
sans-serif;
text-align: center;
}
.i18n-content.mixed-dir {
font-size: 14px;
font-family: system-ui, sans-serif;
text-align: start;
line-height: 1.6;
}
.i18n-content.emoji-text {
font-size: 24px;
text-align: center;
letter-spacing: 0.12em;
line-height: 1.5;
}
.i18n-content.ruby-text {
font-size: 19px;
font-family: "Noto Sans CJK", "Hiragino Sans", "Yu Gothic",
"MS Gothic", system-ui, sans-serif;
text-align: center;
}
.i18n-content.ruby-text ruby {
ruby-position: over;
}
.i18n-content.ruby-text rt {
font-size: 0.5em;
color: #a0a0b8;
}
@media (max-width: 600px) {
.example-body {
grid-template-columns: 1fr;
}
.side:first-child {
border-right: none;
border-bottom: 1px solid #282840;
}
}
</style>
</head>
<body>
<div class="examples">
<!-- ============================================
1. RTL — Arabic & Hebrew
fillText() ignores direction: rtl and draws
left-to-right. drawElementImage() inherits
the browser's bidi algorithm.
============================================ -->
<div class="example">
<div class="example-header">
<span class="script-label">RTL</span>
<span class="script-name">Arabic & Hebrew</span>
</div>
<div class="example-body">
<div class="side filltext">
<span class="side-label">ctx.fillText()</span>
<canvas
id="filltext-rtl"
width="400"
height="175"
aria-label="fillText rendering of RTL Arabic and Hebrew text"
></canvas>
<span class="side-note"
>No bidi algorithm — glyphs render left-to-right, punctuation
misplaced.</span
>
</div>
<div class="side html">
<span class="side-label">drawElementImage()</span>
<canvas
id="html-rtl"
width="400"
height="175"
layoutsubtree
aria-label="drawElementImage rendering of RTL Arabic and Hebrew text"
>
<div class="i18n-content rtl-text">
<div>
<div lang="ar">مرحباً بالعالم! هذا نص عربي.</div>
<div lang="he" style="margin-top: 8px">
!שלום עולם — טקסט עברי
</div>
</div>
</div>
</canvas>
<span class="side-note"
>Browser's bidi algorithm handles direction, punctuation, and
shaping.</span
>
</div>
</div>
</div>
<!-- ============================================
2. Vertical CJK
fillText() has no writing-mode support.
drawElementImage() renders vertical-rl with
proper character rotation and spacing.
============================================ -->
<div class="example">
<div class="example-header">
<span class="script-label">Vertical</span>
<span class="script-name">CJK (Chinese / Japanese / Korean)</span>
</div>
<div class="example-body">
<div class="side filltext">
<span class="side-label">ctx.fillText()</span>
<canvas
id="filltext-cjk"
width="400"
height="175"
aria-label="fillText rendering of vertical CJK text"
></canvas>
<span class="side-note"
>No writing-mode support — text forced horizontal.</span
>
</div>
<div class="side html">
<span class="side-label">drawElementImage()</span>
<canvas
id="html-cjk"
width="400"
height="175"
layoutsubtree
aria-label="drawElementImage rendering of vertical CJK text"
>
<div class="i18n-content vertical-cjk" lang="ja">
<div>
国境の長いトンネルを抜けると雪国であった。
</div>
</div>
</canvas>
<span class="side-note"
>CSS <code>writing-mode: vertical-rl</code> works natively.</span
>
</div>
</div>
</div>
<!-- ============================================
3. Thai script with stacking diacritics
fillText() mispositions vowel marks and
tone marks that stack above/below consonants.
drawElementImage() uses the browser's text
shaping engine for perfect rendering.
============================================ -->
<div class="example">
<div class="example-header">
<span class="script-label">Complex</span>
<span class="script-name">Thai Script with Stacking Diacritics</span>
</div>
<div class="example-body">
<div class="side filltext">
<span class="side-label">ctx.fillText()</span>
<canvas
id="filltext-thai"
width="400"
height="175"
aria-label="fillText rendering of Thai text"
></canvas>
<span class="side-note"
>Stacking marks may misalign — platform-dependent shaping.</span
>
</div>
<div class="side html">
<span class="side-label">drawElementImage()</span>
<canvas
id="html-thai"
width="400"
height="175"
layoutsubtree
aria-label="drawElementImage rendering of Thai text"
>
<div class="i18n-content thai-text" lang="th">
สวัสดีครับ ยินดีต้อนรับสู่เว็บไซต์
</div>
</canvas>
<span class="side-note"
>Browser's text shaping engine handles all mark
positioning.</span
>
</div>
</div>
</div>
<!-- ============================================
4. Mixed-direction text
A single paragraph mixing LTR and RTL runs
with embedded numbers and punctuation.
fillText() cannot handle inline bidi runs.
============================================ -->
<div class="example">
<div class="example-header">
<span class="script-label">Mixed</span>
<span class="script-name">Bidirectional Text with Numbers</span>
</div>
<div class="example-body">
<div class="side filltext">
<span class="side-label">ctx.fillText()</span>
<canvas
id="filltext-mixed"
width="400"
height="175"
aria-label="fillText rendering of mixed-direction text"
></canvas>
<span class="side-note"
>fillText renders a single direction — mixed runs break.</span
>
</div>
<div class="side html">
<span class="side-label">drawElementImage()</span>
<canvas
id="html-mixed"
width="400"
height="175"
layoutsubtree
aria-label="drawElementImage rendering of mixed-direction text"
>
<div class="i18n-content mixed-dir">
<div>
The term
<span dir="rtl" lang="ar" style="font-size: 1.1em"
>إنترنت</span
>
(Internet) was adopted in
<span dir="rtl" lang="ar">١٩٩٥</span> and is used
alongside
<span dir="rtl" lang="he" style="font-size: 1.1em"
>אינטרנט</span
>
in multilingual documents.
</div>
</div>
</canvas>
<span class="side-note"
>Unicode bidi algorithm resolves each embedded run
correctly.</span
>
</div>
</div>
</div>
<!-- ============================================
5. Emoji with skin tones and ZWJ sequences
fillText() may render broken sequences or
fallback glyphs. drawElementImage() uses the
browser's emoji rendering pipeline.
============================================ -->
<div class="example">
<div class="example-header">
<span class="script-label">Emoji</span>
<span class="script-name">Skin Tones & ZWJ Sequences</span>
</div>
<div class="example-body">
<div class="side filltext">
<span class="side-label">ctx.fillText()</span>
<canvas
id="filltext-emoji"
width="400"
height="175"
aria-label="fillText rendering of emoji with skin tone modifiers"
></canvas>
<span class="side-note"
>ZWJ sequences may split into separate glyphs.</span
>
</div>
<div class="side html">
<span class="side-label">drawElementImage()</span>
<canvas
id="html-emoji"
width="400"
height="175"
layoutsubtree
aria-label="drawElementImage rendering of emoji with skin tone modifiers"
>
<div class="i18n-content emoji-text">
👩🏽💻 👨🏿🎨 👩🏻🔬 🧑🏾🍳 👨🏼🚀 👩🏽⚕️
</div>
</canvas>
<span class="side-note"
>Browser's emoji engine composes ZWJ sequences and skin
tones.</span
>
</div>
</div>
</div>
<!-- ============================================
6. Ruby annotations (furigana)
fillText() has no concept of ruby. HTML <ruby>
elements with <rt> render natively via
drawElementImage().
============================================ -->
<div class="example">
<div class="example-header">
<span class="script-label">Ruby</span>
<span class="script-name">Japanese Furigana Annotations</span>
</div>
<div class="example-body">
<div class="side filltext">
<span class="side-label">ctx.fillText()</span>
<canvas
id="filltext-ruby"
width="400"
height="175"
aria-label="fillText rendering of text needing ruby annotations"
></canvas>
<span class="side-note"
>No ruby support — annotations drawn inline, breaking
flow.</span
>
</div>
<div class="side html">
<span class="side-label">drawElementImage()</span>
<canvas
id="html-ruby"
width="400"
height="175"
layoutsubtree
aria-label="drawElementImage rendering of text with ruby annotations"
>
<div class="i18n-content ruby-text" lang="ja">
<ruby>東<rt>ひがし</rt></ruby
><ruby>京<rt>きょう</rt></ruby
><ruby>都<rt>と</rt></ruby
>の<ruby>渋<rt>しぶ</rt></ruby
><ruby>谷<rt>や</rt></ruby
>で<ruby>会<rt>あ</rt></ruby
>いましょう。
</div>
</canvas>
<span class="side-note"
>HTML <ruby> and <rt> render with proper
positioning.</span
>
</div>
</div>
</div>
</div>
<script>
// Resolve the script's root: a ShadowRoot when mounted via shadow
// DOM, or `document` when this file is served standalone.
const root = window.__demoRoot ?? document;
// =============================================================
// SHARED TEXT STRINGS
// Used by both the fillText canvases and (implicitly) the HTML
// content inside the drawElementImage canvases.
// =============================================================
const samples = {
rtl: {
lines: [
"مرحباً بالعالم! هذا نص عربي.",
"!שלום עולם — טקסט עברי",
],
},
cjk: {
text: "国境の長いトンネルを抜けると雪国であった。",
},
thai: {
text: "สวัสดีครับ ยินดีต้อนรับสู่เว็บไซต์",
},
mixed: {
text: 'The term إنترنت (Internet) was adopted in ١٩٩٥ and is used alongside אינטרנט in multilingual documents.',
},
emoji: {
text: "👩🏽💻 👨🏿🎨 👩🏻🔬 🧑🏾🍳 👨🏼🚀 👩🏽⚕️",
},
ruby: {
// For fillText, we can only show the base characters
// with parenthesized readings inline
text: "東(ひがし)京(きょう)都(と)の渋(しぶ)谷(や)で会(あ)いましょう。",
plain: "東京都の渋谷で会いましょう。",
},
};
// =============================================================
// fillText CANVASES
// These demonstrate the limitations: fillText draws a flat
// string of glyphs with no bidi, no writing-mode, no ruby,
// and platform-dependent shaping.
// =============================================================
function paintFillTextCanvas(canvasId, drawFn) {
const canvas = root.getElementById(canvasId);
const ctx = canvas.getContext("2d");
function paint() {
const dpr = devicePixelRatio;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.clearRect(0, 0, w, h);
drawFn(ctx, w, h);
}
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
canvas.width = Math.round(width * devicePixelRatio);
canvas.height = Math.round(height * devicePixelRatio);
ctx.scale(devicePixelRatio, devicePixelRatio);
paint();
});
ro.observe(canvas);
}
// --- 1. RTL (drawn LTR by fillText — wrong!) ---
paintFillTextCanvas("filltext-rtl", (ctx, w, h) => {
ctx.fillStyle = "#f0f0f0";
ctx.font = "20px system-ui, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillText(samples.rtl.lines[0], 20, h / 2 - 16);
ctx.fillText(samples.rtl.lines[1], 20, h / 2 + 20);
});
// --- 2. Vertical CJK (forced horizontal by fillText) ---
paintFillTextCanvas("filltext-cjk", (ctx, w, h) => {
ctx.fillStyle = "#f0f0f0";
ctx.font = '20px "Noto Sans CJK", "Hiragino Sans", system-ui, sans-serif';
ctx.textBaseline = "middle";
ctx.textAlign = "center";
// fillText has no writing-mode — text runs horizontally
// and overflows or wraps manually
const text = samples.cjk.text;
const half = Math.ceil(text.length / 2);
ctx.fillText(text.slice(0, half), w / 2, h / 2 - 14);
ctx.fillText(text.slice(half), w / 2, h / 2 + 18);
});
// --- 3. Thai ---
paintFillTextCanvas("filltext-thai", (ctx, w, h) => {
ctx.fillStyle = "#f0f0f0";
ctx.font =
'22px "Noto Sans Thai", "Leelawadee UI", "Tahoma", system-ui, sans-serif';
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.fillText(samples.thai.text, w / 2, h / 2);
});
// --- 4. Mixed direction ---
paintFillTextCanvas("filltext-mixed", (ctx, w, h) => {
ctx.fillStyle = "#f0f0f0";
ctx.font = "16px system-ui, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
// fillText treats this as a single LTR run
const text = samples.mixed.text;
// Rough word-wrap for the flat string
const maxW = w - 40;
const words = text.split(" ");
let line = "";
let y = h / 2 - 20;
for (const word of words) {
const test = line ? line + " " + word : word;
if (ctx.measureText(test).width > maxW && line) {
ctx.fillText(line, 20, y);
line = word;
y += 24;
} else {
line = test;
}
}
if (line) ctx.fillText(line, 20, y);
});
// --- 5. Emoji ---
paintFillTextCanvas("filltext-emoji", (ctx, w, h) => {
ctx.fillStyle = "#f0f0f0";
ctx.font = "28px system-ui, sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.fillText(samples.emoji.text, w / 2, h / 2);
});
// --- 6. Ruby (shown inline — no annotation support) ---
paintFillTextCanvas("filltext-ruby", (ctx, w, h) => {
ctx.fillStyle = "#f0f0f0";
ctx.font =
'16px "Noto Sans CJK", "Hiragino Sans", system-ui, sans-serif';
ctx.textBaseline = "middle";
ctx.textAlign = "center";
// Show the readings inline in parentheses — the only
// option when you have no ruby layout
ctx.fillText(samples.ruby.text, w / 2, h / 2 - 10);
// Explain the limitation
ctx.fillStyle = "#5a5a70";
ctx.font = "11px system-ui, sans-serif";
ctx.fillText(
"(readings forced inline — no above-text placement)",
w / 2,
h / 2 + 18
);
});
// =============================================================
// drawElementImage CANVASES
// Each HTML canvas uses layoutsubtree + drawElementImage to
// render its child HTML. The browser's full text engine handles
// bidi, shaping, writing-mode, and ruby positioning.
// =============================================================
function setupHtmlCanvas(canvasId) {
const canvas = root.getElementById(canvasId);
const ctx = canvas.getContext("2d");
const content = canvas.firstElementChild;
function paint() {
// Bitmap is 1:1 with CSS pixels — see memory
// project_layoutsubtree_bitmap_layout. Layoutsubtree uses
// canvas.width as the layout viewport for child elements.
ctx.clearRect(0, 0, canvas.width, canvas.height);
const transform = ctx.drawElementImage(content, 0, 0);
if (transform) {
content.style.transform = transform.toString();
}
}
canvas.onpaint = paint;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
canvas.width = Math.round(width);
canvas.height = Math.round(height);
canvas.requestPaint?.();
});
ro.observe(canvas);
}
setupHtmlCanvas("html-rtl");
setupHtmlCanvas("html-cjk");
setupHtmlCanvas("html-thai");
setupHtmlCanvas("html-mixed");
setupHtmlCanvas("html-emoji");
setupHtmlCanvas("html-ruby");
</script>
</body>
</html>