Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/Http/HtmlResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ public function render(): string {
return $this->content;
}

return '<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /><script nonce="' . $this->nonce . '" src="' . $this->scriptUrl . '"></script></head><body>' . $this->content . '<div data-iframe-size></div></body></html>';
return '<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /><script nonce="' . $this->nonce . '" src="' . $this->scriptUrl . '"></script></head><body>' . $this->content . '</body></html>';
}
}
5 changes: 5 additions & 0 deletions lib/Service/Html.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ public function sanitizeHtmlMailBody(int $messageId, string $mailBody, array $in
// Rewrite URL for redirection and proxying of content
/** @var HTMLPurifier_HTMLDefinition $def */
$def = $config->getHTMLDefinition(true);

// HTMLPurifier only knows XHTML 1.0, which predates these
$def->addElement('figure', 'Block', 'Flow', 'Common');
$def->addElement('figcaption', 'Block', 'Flow', 'Common');

$def->info_attr_transform_post['imagesrc'] = new TransformImageSrc($this->urlGenerator);
$def->info_attr_transform_post['cssbackground'] = new TransformStyleURLs($this->urlGenerator);
$def->info_attr_transform_post['htmllinks'] = new TransformHTMLLinks();
Expand Down
47 changes: 47 additions & 0 deletions src/ckeditor/image/ImageDowncastPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ import type { ViewDocumentFragment, ViewElement } from 'ckeditor5'

import { ImageUtils, Plugin, UpcastWriter } from 'ckeditor5'

/**
* Alignment as inline styles, keyed by the class the image style feature writes.
*
* Physical margins because the Word engine behind Outlook ignores margin-inline.
* The margins place a figure narrower than the available space, text-align
* places the image inside a full width figure.
*/
const ALIGNMENTS: Record<string, Record<string, string>> = {
'image-style-align-center': { 'margin-left': 'auto', 'margin-right': 'auto', 'text-align': 'center' },
'image-style-block-align-right': { 'margin-left': 'auto', 'margin-right': '0', 'text-align': 'right' },
'image-style-block-align-left': { 'margin-left': '0', 'margin-right': 'auto', 'text-align': 'left' },
}

/**
* Parse a CSS length into whole pixels. Anything but an absolute pixel value
* yields null.
Expand Down Expand Up @@ -49,6 +62,10 @@ export default class ImageDowncastPlugin extends Plugin {
const writer = new UpcastWriter(fragment.document)

for (const { item } of writer.createRangeIn(fragment)) {
if (item.is('element', 'figure') && item.hasClass('image')) {
this._inlineAlignment(writer, item)
}

// A block image carries the resized width on its figure, an inline
// one on the img itself.
if ((item.is('element', 'figure') && item.hasClass('image')) || item.is('element', 'img')) {
Expand All @@ -58,6 +75,36 @@ export default class ImageDowncastPlugin extends Plugin {
}, { priority: 'low' })
}

/**
* Adds the inline styles for the figure's alignment class, which is backed by
* an editor stylesheet recipients never load. The class stays so that
* reopening the draft restores the active alignment.
*
* @param writer view writer of the data view
* @param figure the figure to align
*/
_inlineAlignment(writer: UpcastWriter, figure: ViewElement): void {
// Fall back to left: without a class the figure keeps the client's own margins.
const className = Object.keys(ALIGNMENTS).find((candidate) => figure.hasClass(candidate))
?? 'image-style-block-align-left'
const alignment = ALIGNMENTS[className]

writer.setStyle(alignment, figure)

const image = this.editor.plugins.get('ImageUtils').findViewImgElement(figure)
if (image === undefined) {
return
}

// Clients that predate <figure> drop the tag and keep the img, so the img
// has to align itself. Auto margins only move a block element.
writer.setStyle({
display: 'block',
'margin-left': alignment['margin-left'],
'margin-right': alignment['margin-right'],
}, image)
}
Comment on lines +86 to +106

/**
* Mirrors a resized image's CSS width onto the img width attribute, which
* clients that drop CSS still honour. Reopening the message reads that width
Expand Down
1 change: 1 addition & 0 deletions src/components/MessageHTMLBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export default {
license: 'GPLv3',
log: false,
scrolling: true,
warningTimeout: 10000,
}, this.$refs.iframe)

this.detectedForeignLanguage = await detectForeignLanguage(this.message.body ?? '')
Expand Down
19 changes: 19 additions & 0 deletions src/components/TextEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
Heading,
Image,
ImageResize,
ImageStyle,
ImageToolbar,
ImageUpload,
Italic,
Link,
Expand Down Expand Up @@ -152,6 +154,8 @@ export default {
Image,
ImageUpload,
ImageResize,
ImageStyle,
ImageToolbar,
FilesImagePlugin,
ImageDowncastPlugin,
Font,
Expand Down Expand Up @@ -206,6 +210,15 @@ export default {
image: {
// A percentage would be relative to the recipient's unknown viewport.
resizeUnit: 'px',
styles: {
options: ['alignBlockLeft', 'alignCenter', 'alignBlockRight'],
},

toolbar: [
'imageStyle:alignBlockLeft',
'imageStyle:alignCenter',
'imageStyle:alignBlockRight',
],
},

mention: {
Expand Down Expand Up @@ -718,6 +731,12 @@ export default {
cursor: text;
margin: 0 !important;
}

/* CKEditor centres a block image without a style class; match the left aligned
option, which is what ImageDowncast writes for it. */
:deep(.ck-content .image:not([class*='image-style'])) {
margin-inline: 0 auto;
}
</style>

<style>
Expand Down
52 changes: 50 additions & 2 deletions src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { ClassicEditor, ImageBlock, ImageInline, ImageResizeEditing, Paragraph } from 'ckeditor5'
import { ClassicEditor, ImageBlock, ImageInline, ImageResizeEditing, ImageStyleEditing, Paragraph } from 'ckeditor5'
import ImageDowncastPlugin from '../../../../ckeditor/image/ImageDowncastPlugin.ts'

// The editor UI observes the size of its toolbar, which jsdom does not provide.
Expand All @@ -26,9 +26,12 @@ async function downcast(initialData) {
const editor = await ClassicEditor.create(element, {
licenseKey: 'GPL',
initialData,
plugins: [Paragraph, ImageBlock, ImageInline, ImageResizeEditing, ImageDowncastPlugin],
plugins: [Paragraph, ImageBlock, ImageInline, ImageResizeEditing, ImageStyleEditing, ImageDowncastPlugin],
image: {
resizeUnit: 'px',
styles: {
options: ['alignBlockLeft', 'alignCenter', 'alignBlockRight'],
},
},
})

Expand Down Expand Up @@ -109,4 +112,49 @@ describe('ImageDowncastPlugin', () => {
expect(resent).toContain('width:200px;')
expect(resent).not.toContain('height=')
})

it('inlines the styles of a centred image', async () => {
const data = await downcast('<figure class="image image-style-align-center"><img src="test.png"></figure>')

expect(data).toContain('margin-left:auto;')
expect(data).toContain('margin-right:auto;')
expect(data).toContain('text-align:center;')
expect(data).toContain('image-style-align-center')
})

it('inlines the styles of a right aligned image', async () => {
const data = await downcast('<figure class="image image-style-block-align-right"><img src="test.png"></figure>')

expect(data).toContain('margin-left:auto;')
expect(data).toContain('margin-right:0;')
expect(data).toContain('text-align:right;')
})

it('left aligns an image without a style class', async () => {
const data = await downcast('<figure class="image"><img src="test.png"></figure>')

expect(data).toContain('margin-left:0;')
expect(data).toContain('margin-right:auto;')
expect(data).toContain('text-align:left;')
})

it('aligns the img itself so it survives a stripped figure', async () => {
const data = await downcast('<figure class="image image-style-block-align-right"><img src="test.png"></figure>')

expect(data).toContain('<img style="display:block;margin-left:auto;margin-right:0;"')
})

it('leaves an inline image unaligned', async () => {
const data = await downcast('<p>text <img src="test.png"></p>')

expect(data).not.toContain('margin-left')
expect(data).not.toContain('text-align')
})

it('keeps the alignment when the message is reopened and sent again', async () => {
const sent = await downcast('<figure class="image image-style-align-center"><img src="test.png"></figure>')
const resent = await downcast(sent)

expect(resent).toContain('text-align:center;')
})
})
3 changes: 2 additions & 1 deletion src/tests/unit/components/TextEditor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('TextEditor', () => {
expect(wrapper.vm.config.htmlSupport.allow.some((rule) => rule.name === 'img')).toBe(true)
})

it('resizes images in pixels in html mode', async () => {
it('aligns and resizes images in html mode', async () => {
const wrapper = shallowMount(TextEditor, {
localVue,
provide: {
Expand All @@ -72,6 +72,7 @@ describe('TextEditor', () => {

expect(wrapper.vm.config.plugins).toContain(ImageDowncastPlugin)
expect(wrapper.vm.config.image.resizeUnit).toBe('px')
expect(wrapper.vm.config.image.toolbar).toContain('imageStyle:alignCenter')
})

it('throw when editor not ready', async () => {
Expand Down
17 changes: 17 additions & 0 deletions tests/Unit/Service/HtmlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,21 @@ public function testSanitizeHtmlMailBodyPreservesId(): void {

$this->assertStringContainsString('id="target"', $result);
}

public function testSanitizeHtmlMailBodyPreservesFigure(): void {
$urlGenerator = $this->createStub(IURLGenerator::class);
$urlGenerator->method('linkToRoute')->willReturn('/apps/mail/proxy?id=42&hmac=abc');
$request = $this->createStub(IRequest::class);
$hmacGenerator = $this->createStub(ProxyHmacGenerator::class);

$html = new Html($urlGenerator, $request, $hmacGenerator);
$result = $html->sanitizeHtmlMailBody(
42,
'<figure class="image image-style-align-center"><img src="https://example.com/i.png" alt=""><figcaption>Caption</figcaption></figure>',
[],
);

$this->assertStringContainsString('<figure class="image image-style-align-center">', $result);
$this->assertStringContainsString('<figcaption>Caption</figcaption>', $result);
}
}
Loading