fix: render right-to-left mail right-to-left

The HTML and plain-text body documents had no direction, so an Arabic
or Hebrew message was laid out left-to-right: left-aligned, with its
sentence-final punctuation on the wrong side. Port the webmail's
`dir="auto"` on the body of both documents so the first strong
character picks the direction. An email that declares its own `dir`
on its `<html>` or `<body>` tag keeps it; the parser merges those
attributes into ours only while ours has none.

The shrink-to-fit reporter widens its wrapper to the content's natural
width and scales it from the top left. In a right-to-left body that
over-wide block overflowed to the left instead, so a 600px newsletter
scaled into the space left of the screen. A negative right margin now
keeps the widened wrapper flush left in either direction, so the
existing origin, pan clamp and pinch anchoring still hold.
This commit is contained in:
Linus Rath
2026-09-24 18:50:55 +02:00
parent 7531118727
commit 910c503f4a
3 changed files with 36 additions and 2 deletions
+6
View File
@@ -248,10 +248,15 @@ const HEIGHT_REPORTER = `
if (Z.tx < minTx) Z.tx = minTx;
if (Z.tx > 0) Z.tx = 0;
w.style.width = Z.layoutW + 'px';
// An over-wide block in a right-to-left body overflows to the left,
// where the top-left origin and the pan clamp don't expect it. The
// negative right margin keeps it flush left in either direction.
w.style.marginRight = (Z.avail - Z.layoutW) + 'px';
w.style.transform = 'translateX(' + Z.tx + 'px) scale(' + s + ')';
} else {
Z.tx = 0;
w.style.width = '100%';
w.style.marginRight = '';
w.style.transform = 'none';
}
}
@@ -266,6 +271,7 @@ const HEIGHT_REPORTER = `
// Reset to full width so wrapping content reports its true overflow extent
// (the widest unbreakable element) rather than a previously-scaled box.
w.style.width = '100%';
w.style.marginRight = '';
var content = w.scrollWidth;
Z.avail = avail;
Z.layoutW = content > avail + 1 ? content : avail;
+18
View File
@@ -205,6 +205,20 @@ describe('prepareEmailHtml', () => {
expect(html).toContain('max-height:134px; max-width:200px; width:100%;');
expect(html).toContain('style="max-width:300px; width:100%"');
});
it('lets the content pick its text direction', () => {
expect(prepareEmailHtml('<p>مرحبا بالعالم.</p>').html).toContain('<body dir="auto">');
});
it('keeps a direction the email declares on its own html or body tag', () => {
const body = prepareEmailHtml('<body dir="rtl"><p>Newsletter مرحبا</p></body>').html;
expect(body).not.toContain('dir="auto"');
expect(body).toContain('<body dir="rtl">');
const root = prepareEmailHtml('<html lang="ar" dir="rtl"><body><p>x</p></body></html>').html;
expect(root).not.toContain('dir="auto"');
// A dir on an inner element is not a document direction.
expect(prepareEmailHtml('<p dir="rtl">x</p>').html).toContain('<body dir="auto">');
});
});
describe('wrapPlainTextEmail', () => {
@@ -213,6 +227,10 @@ describe('wrapPlainTextEmail', () => {
expect(wrapPlainTextEmail('x', { font: 'mono' })).toContain('ui-monospace');
expect(wrapPlainTextEmail('x')).not.toContain('ui-monospace');
});
it('lets the content pick its text direction', () => {
expect(wrapPlainTextEmail('مرحبا بالعالم.')).toContain('<body dir="auto">');
});
});
describe('hasNativeDarkMode', () => {
+12 -2
View File
@@ -526,6 +526,13 @@ function isWordHtml(html: string): boolean {
return /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(html);
}
// The parser merges the attributes of an email's own <html>/<body> tags into
// our document's where ours lack them, so a direction the sender declared
// there applies as long as we don't set one first.
function declaresOwnDirection(html: string): boolean {
return /<(?:html|body)\b[^>]*\sdir\s*=/i.test(html);
}
// "auto" spacing: only drop our gutter when the mail paints a full-bleed
// background canvas (a width:100% element carrying a background colour) - the
// one case where the gutter shows as a frame around the email's own
@@ -633,6 +640,9 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
const autoDropsGutter = hasStyleTag && !word && hasFullBleedCanvas(processed);
const dropGutter = messageSpacing === 'edge' || (messageSpacing === 'auto' && autoDropsGutter);
const bodyPadding = dropGutter ? '0' : '16px';
// dir="auto" lets the first strong character pick the direction, so
// right-to-left mail reads right-to-left.
const bodyDir = declaresOwnDirection(processed) ? '' : ' dir="auto"';
const html = `<!doctype html>
<html style="color-scheme: ${colorScheme};">
@@ -643,7 +653,7 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
<meta name="referrer" content="no-referrer">
<style>${baseStyles(bodyPadding)}${word ? WORD_HTML_CSS : ''}${darkCss}</style>
</head>
<body>${processed}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
<body${bodyDir}>${processed}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
</html>`;
return { html, applyInversion, hasNativeDark, blockedExternal };
@@ -701,7 +711,7 @@ details > summary::-webkit-details-marker { display: none; }
<meta http-equiv="Content-Security-Policy" content="${buildCsp(true)}">
<style>${styles}</style>
</head>
<body>${cleaned}</body>
<body dir="auto">${cleaned}</body>
</html>`;
}