UNPKG

dom-to-image-more

Version:

Generates an image from a DOM node using HTML5 canvas and SVG

916 lines (886 loc) 205 kB
/* eslint-disable no-undef */ (function (global) { 'use strict'; const assert = global.chai.assert; const domtoimage = global.domtoimage; const Promise = global.Promise; const Tesseract = global.Tesseract; const BASE_URL = '/base/spec/resources/'; const validPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY7h79y4ABTICmGnXPbMAAAAASUVORK5CYII='; // When the suite is launched with UPDATE_CONTROLS=1 the karma config sets this // flag; instead of asserting against the stored control images, the comparison // helpers POST each freshly-rendered image back to the karma updater middleware, // which overwrites the matching control-image file. Run it in the SAME // environment that validates the suite in CI (font rasterization/DPR differ). const UPDATE_CONTROLS = !!( global.__karma__ && global.__karma__.config && global.__karma__.config.updateControls ); // Tests that compare a render against a stored control image are OS-font-dependent // (ClearType vs FreeType, etc.), so they can't run cross-OS in CI. They are declared // with `itImage(...)` instead of `it(...)`; under LOGIC_ONLY the karma config sets // `logicOnly` and they're skipped, leaving the OS-robust logic subset to run anywhere. const LOGIC_ONLY = !!( global.__karma__ && global.__karma__.config && global.__karma__.config.logicOnly ); function itImage(title, fn) { return (LOGIC_ONLY ? it.skip : it)(title, fn); } let currentControlPath = null; function writeControlImage(dataUrl) { if (!currentControlPath) { return Promise.reject( new Error('UPDATE_CONTROLS: no control-image path for this test') ); } return fetch('/__update_control__', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: currentControlPath, data: dataUrl }), }).then(function (res) { if (!res.ok) { return res.text().then(function (text) { throw new Error('control update failed: ' + text); }); } }); } describe('domtoimage', function () { afterEach(purgePage); it('should load', function () { assert.ok(domtoimage); }); describe('rendering', function () { describe('content', function () { describe('svg', function () { it('renders an out-of-subtree icon-sprite <use xlink:href> (#139)', function (done) { // #139: an SVG icon used via `<use xlink:href="#id">` referencing a // `<symbol>` defined OUTSIDE the rendered subtree (the Vue/webpack // svg-sprite pattern) was missing from the export. Closed by #215's // collect-and-inject (legacy xlink:href + currentColor preserved). loadTestPage() .then(function () { const sprite = '<svg style="display:none" xmlns="http://www.w3.org/2000/svg">' + '<symbol id="icon-star" viewBox="0 0 10 10">' + '<path id="starpath" d="M5 0 L6 4 L10 4 L7 6 L8 10 L5 7 L2 10 L3 6 L0 4 L4 4 Z" fill="currentColor"></path>' + '</symbol></svg>'; document .querySelector('#test-root') .insertAdjacentHTML('afterbegin', sprite); domNode().innerHTML = '<div style="color:red"><svg class="icon" width="20" height="20" xmlns="http://www.w3.org/2000/svg">' + '<use xlink:href="#icon-star"></use></svg></div>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); assert.include( decoded, 'id="icon-star"', 'referenced symbol must be injected' ); assert.include( decoded, 'id="starpath"', "the symbol's contents must be injected" ); }) .then(done) .catch(done); }); it('renders a non-root SVG <g> element (#205)', function (done) { // #205: rendering a non-root SVG element (`<g>`, `<path>`, …) directly used // to reject — the bare element was wrapped in an XHTML <foreignObject> where // it can't render. It is now wrapped in a real <svg> framed by its getBBox, // so it rasterizes. Render the <g> and sample a pixel inside its red rect. loadTestPage() .then(function () { domNode().innerHTML = '<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">' + '<g id="grp" transform="translate(10,10)">' + '<rect x="0" y="0" width="40" height="30" fill="rgb(255,0,0)"></rect>' + '<circle cx="60" cy="20" r="15" fill="rgb(0,0,255)"></circle>' + '</g></svg>'; return domtoimage.toPixelData( document.getElementById('grp') ); }) .then(function (pixels) { assert.isAbove( pixels.length, 0, 'should produce pixel data, not reject' ); // bbox is 75x35; sample (5,5) which is inside the red rect. const i = (5 * 75 + 5) * 4; assert.isAbove( pixels[i], 200, 'red channel at the rect should be high' ); assert.isBelow( pixels[i + 2], 80, 'blue channel at the rect should be low' ); assert.isAbove( pixels[i + 3], 200, 'pixel at the rect should be opaque' ); }) .then(done) .catch(done); }); it('inlines a nested SVG <image> href (#121)', function (done) { // An SVG <image> references its bitmap via href / xlink:href (not .src // like an HTML <img>), so it must be fetched and embedded as a data URL // too, or it survives as an unfetchable external URL in the output. this.timeout(15000); const url = '/base/spec/resources/images/image.png'; loadTestPage() .then(function () { domNode().innerHTML = '<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg">' + '<image id="img" href="' + url + '" width="40" height="40"></image>' + '</svg>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); const image = (decoded.match(/<image[^>]*>/) || [])[0]; assert.isString(image, '<image> should be in the output'); assert.include( image, 'data:image', 'the SVG <image> href must be inlined as a data: URL' ); assert.notInclude( image, 'image.png', 'the external href must not survive in the output' ); }) .then(done) .catch(done); }); it('inlines out-of-subtree SVG referenced by <use> (#215)', function (done) { // #215: a <use> that references a <symbol>/element defined OUTSIDE the // rendered subtree would render nothing, because the referenced node was // never cloned. We now collect the target and inject it into the output // SVG so the reference resolves in the standalone image. loadTestPage() .then(function () { const sprite = '<svg style="display:none" xmlns="http://www.w3.org/2000/svg">' + '<symbol id="diamond" viewBox="0 0 10 10">' + '<rect id="symrect" x="2" y="2" width="6" height="6" fill="red"></rect>' + '</symbol></svg>'; // Sibling of #dom-node, NOT inside it — so it is never cloned. document .querySelector('#test-root') .insertAdjacentHTML('afterbegin', sprite); domNode().innerHTML = '<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg">' + '<use href="#diamond" width="20" height="20"></use></svg>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); // The referenced symbol (and its contents) must be present in // the standalone output, otherwise <use href="#diamond"> is // dangling and nothing rasterizes. assert.include( decoded, 'id="diamond"', 'referenced <symbol> should be injected into the output' ); assert.include( decoded, 'id="symrect"', "referenced symbol's contents should be injected" ); }) .then(done) .catch(done); }); it('does not duplicate <use> targets already in the subtree (#215)', function (done) { // #215: when the referenced id already exists inside the rendered // subtree, we must NOT inject a duplicate of it. loadTestPage() .then(function () { domNode().innerHTML = '<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg">' + '<defs><rect id="inside" x="0" y="0" width="6" height="6" fill="blue"></rect></defs>' + '<use href="#inside" width="20" height="20"></use></svg>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); const occurrences = decoded.split('id="inside"').length - 1; assert.equal( occurrences, 1, 'in-subtree target must not be duplicated' ); }) .then(done) .catch(done); }); it('should render nested svg with broken namespace', function (done) { // svg/path declare the (wrong) XHTML namespace; the path must still // survive serialization rather than being dropped or throwing. loadTestPage('svg-ns/dom-node.html', 'svg-ns/style.css') .then(function () { return renderToSvg(domNode()); }) .then(function (svg) { assert.include( decodeURIComponent(svg), 'M10 10 H 90 V 90 H 10 L 10 10', 'the path data must survive the broken namespace' ); }) .then(done) .catch(done); }); it('should render svg <rect> with width and height', function (done) { loadTestPage('svg-rect/dom-node.html', 'svg-rect/style.css') .then(function () { return renderToSvg(domNode()); }) .then(function (svg) { const rect = (decodeURIComponent(svg).match( /<rect[^>]*\/?>/ ) || [])[0]; assert.isString(rect, '<rect> should be in the output'); assert.match( rect, /width="100"/, 'rect width must be preserved' ); assert.match( rect, /height="100"/, 'rect height must be preserved' ); }) .then(done) .catch(done); }); it('should render an HTML <a> (not confused with SVG <a>)', function (done) { // issue #90: an HTML anchor must round-trip with its attribute-selector // styling applied, not be treated as an SVG <a> element. loadTestPage('svg-styles/dom-node.html', 'svg-styles/style.css') .then(function () { return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); const anchor = (decoded.match(/<a [^>]*>/) || [])[0]; assert.isString(anchor, '<a> should be in the output'); assert.include( anchor, 'href="#dom-node"', 'anchor href must round-trip' ); assert.match( anchor, /color:\s*rgb\(0,\s*0,\s*0\)/, 'attribute-selector color must be applied' ); }) .then(done) .catch(done); }); itImage( 'should render open shadow DOM roots with assigned nodes intact', function (done) { this.timeout(60000); loadTestPage( 'shadow-dom/dom-node.html', 'shadow-dom/styles.css', 'shadow-dom/control-image' ) .then(renderToPngAndCheck) .then(done) .catch(done); } ); it('should not get fooled by math elements', function (done) { loadTestPage() .then(function () { domNode().innerHTML = '<math id="m"><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow></math>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); // MathML must not break the default-style/ascent-stopper path // (lowercase 'math'); the render succeeds and structure survives. assert.include( decoded, 'id="m"', 'the <math> element should render without error' ); assert.include( decoded, '<mi', 'math children should be preserved' ); }) .then(done) .catch(done); }); }); describe('images', function () { it('inlines a CSS mask-image url (#195)', function (done) { // #195: an SVG icon rendered on an element via a CSS mask // (`mask: url(icon.svg); background: currentColor`) came out blank because // the inliner only inlined `background`/`background-image`, leaving the mask // url external — and the standalone output can't fetch external resources. // The inliner now inlines mask urls too. this.timeout(15000); const url = '/base/spec/resources/images/image.png'; loadTestPage() .then(function () { domNode().innerHTML = '<i id="maskicon" style="display:inline-block;width:20px;height:20px;' + 'background-color:red;-webkit-mask-image:url(' + url + ');mask-image:url(' + url + ')"></i>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); const mask = (decoded.match(/<i id="maskicon"[^>]*>/) || [])[0]; assert.isString( mask, 'masked icon should be in the output' ); assert.include( mask, 'data:image', 'the mask url must be inlined as a data: URL' ); assert.notInclude( mask, 'image.png', 'the external mask url must not survive (unfetchable in the output)' ); }) .then(done) .catch(done); }); it('inlines a CSS mask shorthand url (#127)', function (done) { // #127: the `-webkit-mask` / `mask` *shorthand* (not just the // `-image` longhand) carries the url; it must be inlined too or the // masked icon renders blank in the standalone output. this.timeout(15000); const url = '/base/spec/resources/images/image.png'; loadTestPage() .then(function () { domNode().innerHTML = '<i id="shortmask" style="display:inline-block;width:20px;height:20px;' + 'background-color:red;-webkit-mask:url(' + url + ') center / contain no-repeat;mask:url(' + url + ') center / contain no-repeat"></i>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); const mask = (decoded.match(/<i id="shortmask"[^>]*>/) || [])[0]; assert.isString( mask, 'masked icon should be in the output' ); assert.include( mask, 'data:image', 'the shorthand mask url must be inlined as a data: URL' ); assert.notInclude( mask, 'image.png', 'the external mask url must not survive (unfetchable in the output)' ); }) .then(done) .catch(done); }); it('inlines a ::before background-image url (#16)', function (done) { // A background-image on a ::before/::after pseudo-element lives // inside a <style> rule that the element-style image inliner // never visits. this.timeout(15000); const url = '/base/spec/resources/images/image.png'; const style = document.createElement('style'); style.id = 'pseudo16'; style.textContent = '#p16::before { content: ""; display: inline-block;' + ' width: 20px; height: 20px; background-image: url(' + url + '); }'; document.head.appendChild(style); function cleanup() { const el = document.getElementById('pseudo16'); if (el) { el.remove(); } } loadTestPage() .then(function () { domNode().innerHTML = '<div id="p16">x</div>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); assert.include( decoded, 'data:image', 'the ::before background-image must be inlined as a data: URL' ); assert.notInclude( decoded, 'image.png', 'the external pseudo background url must not survive' ); }) .then(cleanup) .then(done) .catch(function (e) { cleanup(); done(e); }); }); it('adjustPseudoElement returning false drops a ::after pseudo-element (#244)', function (done) { const style = document.createElement('style'); style.id = 'pf244'; style.textContent = '#pf244host::before { content: "KEEPBEFORE244"; }' + ' #pf244host::after { content: "DROPAFTER244"; }'; document.head.appendChild(style); function cleanup() { const el = document.getElementById('pf244'); if (el) { el.remove(); } } loadTestPage() .then(function () { domNode().innerHTML = '<div id="pf244host">x</div>'; return renderToSvg(domNode(), { adjustPseudoElement: function (node, pseudo) { return node.id === 'pf244host' && pseudo === ':after' ? false : undefined; }, }); }) .then(function (svg) { const decoded = decodeURIComponent(svg); assert.include( decoded, 'KEEPBEFORE244', 'the kept ::before content must survive' ); assert.notInclude( decoded, 'DROPAFTER244', 'the filtered-out ::after must not be recreated' ); }) .then(cleanup) .then(done) .catch(function (e) { cleanup(); done(e); }); }); it('adjustPseudoElement receives the node, pseudo, and style; true keeps it (#244)', function (done) { const style = document.createElement('style'); style.id = 'pf244b'; style.textContent = '#pf244bhost::before { content: "BOTH244"; }' + ' #pf244bhost::after { content: "BOTH244"; }'; document.head.appendChild(style); const seen = []; function cleanup() { const el = document.getElementById('pf244b'); if (el) { el.remove(); } } loadTestPage() .then(function () { domNode().innerHTML = '<div id="pf244bhost">x</div>'; return renderToSvg(domNode(), { adjustPseudoElement: function (node, pseudo, s) { seen.push({ id: node && node.id, pseudo: pseudo, hasStyle: typeof s.getPropertyValue === 'function', }); return true; }, }); }) .then(function (svg) { const pseudos = seen .filter(function (c) { return c.id === 'pf244bhost'; }) .map(function (c) { return c.pseudo; }); assert.include(pseudos, ':before'); assert.include(pseudos, ':after'); assert.isTrue( seen.every(function (c) { return c.hasStyle; }), 'style argument must be a CSSStyleDeclaration' ); assert.include( decodeURIComponent(svg), 'BOTH244', 'returning true keeps the pseudo-elements' ); }) .then(cleanup) .then(done) .catch(function (e) { cleanup(); done(e); }); }); it('adjustPseudoElement returning overrides tweaks the pseudo (em-dash → hyphen) (#244)', function (done) { const style = document.createElement('style'); style.id = 'pf244c'; style.textContent = '#pf244chost::before { content: "EMDASH\\2014END244"; }'; document.head.appendChild(style); function cleanup() { const el = document.getElementById('pf244c'); if (el) { el.remove(); } } loadTestPage() .then(function () { domNode().innerHTML = '<div id="pf244chost">x</div>'; return renderToSvg(domNode(), { adjustPseudoElement: function (node, pseudo) { // swap the em-dash glyph for a hyphen on the // host's ::before return node.id === 'pf244chost' && pseudo === ':before' ? { content: '"HYPHEN-END244"' } : undefined; }, }); }) .then(function (svg) { assert.include( decodeURIComponent(svg), 'HYPHEN-END244', 'the content override must be applied (and win)' ); }) .then(cleanup) .then(done) .catch(function (e) { cleanup(); done(e); }); }); it('adjustPseudoElement returning an empty object keeps the pseudo unchanged (#244)', function (done) { const style = document.createElement('style'); style.id = 'pf244d'; style.textContent = '#pf244dhost::before { content: "EMPTYOBJ244"; }'; document.head.appendChild(style); function cleanup() { const el = document.getElementById('pf244d'); if (el) { el.remove(); } } loadTestPage() .then(function () { domNode().innerHTML = '<div id="pf244dhost">x</div>'; return renderToSvg(domNode(), { adjustPseudoElement: function () { return {}; // adjust nothing — keep as-is }, }); }) .then(function (svg) { assert.include( decodeURIComponent(svg), 'EMPTYOBJ244', 'an empty overrides object must keep the pseudo' ); }) .then(cleanup) .then(done) .catch(function (e) { cleanup(); done(e); }); }); it('emits clean url() quotes for background-image (#191)', function (done) { // #191: a CSS `url()` set via options.style (the browser normalizes it to // double quotes) was serialized inside the double-quoted style attribute as // `url(&quot;…&quot;)`. We now emit clean single-quoted `url('…')`. loadTestPage() .then(function () { domNode().innerHTML = '<div id="bg">x</div>'; return domtoimage.toSvg(document.getElementById('bg'), { style: { 'background-image': "url('https://example.com/img.png')", }, }); }) .then(function (svg) { const bg = (svg.match(/<div id="bg"[^>]*>/) || [])[0]; assert.isString( bg, 'the styled div should be in the output' ); assert.notInclude( bg, '&quot;', 'url() quotes must not be HTML-escaped' ); assert.include( bg, "url('https://example.com/img.png')", 'background-image should carry a clean single-quoted url()' ); }) .then(done) .catch(done); }); it('should render images', function (done) { this.timeout(30000); loadTestPage('images/dom-node.html', 'images/style.css') .then(renderToPng) .then(drawDataUrl) .then(assertTextRendered(['PNG', 'JPG'])) .then(done) .catch(done); }); itImage('should render active image in srcset', function (done) { this.timeout(30000); loadTestPage( 'srcset/dom-node.html', 'srcset/style.css', 'srcset/control-image' ) .then(renderToPng) .then(check) .then(done) .catch(done); }); itImage('should render background images', function (done) { loadTestPage( 'css-bg/dom-node.html', 'css-bg/style.css', 'css-bg/control-image' ) .then(renderToPngAndCheck) .then(done) .catch(done); }); itImage('should render iframe of street view', function (done) { this.timeout(60000); loadTestPage( 'iframe/street-view.html', 'iframe/style.css', 'iframe/control-image' ) .then(renderToPngAndCheck) .then(done) .catch(done); }); it('should render content from <canvas>', function (done) { loadTestPage('canvas/dom-node.html', 'canvas/style.css') .then(function () { const canvas = document.getElementById('content'); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000000'; ctx.font = '100px monospace'; ctx.fillText('0', canvas.width / 2, canvas.height / 2); }) .then(renderToPng) .then(drawDataUrl) .then(assertTextRendered(['0'])) .then(done) .catch(done); }); it('should handle zero-width <canvas>', function (done) { loadTestPage('canvas/empty-data.html', 'canvas/empty-style.css') .then(renderToSvg) .then(function (dataUrl) { const img = new Image(); document.getElementById('result').appendChild(img); img.src = dataUrl; }) .then(done) .catch(done); }); it('should fill the whole canvas to its edges (guards Firefox crop #160 / blank #146)', function (done) { // A solid-color box must cover every corner of the output. Firefox // could crop a <foreignObject> capture to a default intrinsic box // (#160) or read a blank canvas before the image finished decoding // (#146); both leave outer pixels transparent. The fixes draw into an // explicit destination rectangle and await decode() before reading. // Solid-color pixel sampling is OS-independent, so this guards both // engines in CI (where the real Firefox crop/blank would surface). loadTestPage() .then(function () { domNode().innerHTML = '<div id="fill" style="width:60px;height:40px;background-color:red"></div>'; return domtoimage.toPixelData( document.getElementById('fill') ); }) .then(function (px) { const width = 60; function offset(x, y) { return (y * width + x) * 4; } [ ['center', offset(30, 20)], ['top-left', offset(2, 2)], ['top-right', offset(57, 2)], ['bottom-left', offset(2, 37)], ['bottom-right', offset(57, 37)], ].forEach(function (sample) { const name = sample[0]; const i = sample[1]; assert.equal(px[i], 255, name + ' red channel'); assert.equal(px[i + 1], 0, name + ' green channel'); assert.equal(px[i + 2], 0, name + ' blue channel'); assert.isAbove(px[i + 3], 200, name + ' opaque'); }); }) .then(done) .catch(done); }); }); describe('fonts', function () { it('captures an icon-font glyph from a ::before pseudo-element (#149)', function (done) { // #149: an icon delivered as an `@font-face` glyph via a `::before` // pseudo-element. The composition works — the web font is embedded and the // pseudo-element's `content` glyph + font-family are captured. (The original // report's failure was an external icon font that couldn't be embedded — // the documented web-font/CORS limitation — not a distinct bug.) this.timeout(15000); const style = document.createElement('style'); style.id = 's149'; style.textContent = "@font-face { font-family: 'Ico149'; src: url('/base/tests/fontawesome/webfonts/fa-solid-900.woff2') format('woff2'); }" + '#icon::before { content: "\\2605"; font-family: "Ico149"; font-size: 30px; }'; document.head.appendChild(style); function cleanup() { const el = document.getElementById('s149'); if (el) { el.remove(); } } loadTestPage() .then(function () { domNode().innerHTML = '<span id="icon"></span>'; return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); // The web font must be inlined... assert.include( decoded, 'base64', 'the @font-face glyph font must be embedded' ); // ...and the pseudo-element must keep its glyph + font. assert.include( decoded, 'content: "★"', 'the ::before glyph content must be preserved' ); assert.include( decoded, 'Ico149', 'the icon font-family must reach the pseudo-element' ); }) .then(cleanup) .then(done) .catch(function (e) { cleanup(); done(e); }); }); it('preserves an overridden font-size on headings (#227)', function (done) { // #227 (part 2): an element whose UA font-size is relative to its parent // (h1–h6 are N.Nem). When the page overrides it to coincide with both the // context-free sandbox default AND the parent, the diff dropped it — but // the standalone output resolves the UA relative rule against a different // parent font-size, so it diverged. We now always emit font-size for such // elements. const style = document.createElement('style'); style.id = 'reset-227b'; // Parent 24px; h2 overridden to 1em (= 24px), which coincides with // the UA-default h2 (1.5em of 16px = 24px) and the parent — the exact // drop case. Without the fix the output h2 re-applies UA 1.5em → 36px. style.textContent = '#dom-node { font-size: 24px; } #hd { font-size: 1em; }'; document.head.appendChild(style); function cleanup() { const el = document.getElementById('reset-227b'); if (el) { el.remove(); } } let liveFontSize = ''; loadTestPage() .then(function () { domNode().innerHTML = '<h2 id="hd">Heading</h2>'; liveFontSize = getComputedStyle( document.getElementById('hd') ).getPropertyValue('font-size'); return renderToSvg(domNode()); }) .then(function (svg) { const decoded = decodeURIComponent(svg); const h2 = (decoded.match(/<h2 id="hd"[^>]*>/) || [])[0]; assert.isString(h2, 'h2 should be in the output'); assert.match( h2, new RegExp( 'font-size:\\s*' + liveFontSize.replace('.', '\\.') ), 'h2 must pin its overridden font-size (' + liveFontSize + ') so the UA 1.5em rule doe