Compare commits

...

8 Commits

Author SHA1 Message Date
Dan Brown
bf0ba9f756 Replaced embeds with images in exports 2022-05-24 18:05:47 +01:00
Dan Brown
05f8034439 Added embed support for contained HTML exports
Unfortunately CSP rules will block embeds anyway.
Need to either relax CSP rules on exports, or instead convert to img
tags?

Also cleaned up existing regexes.
2022-05-23 16:11:28 +01:00
Dan Brown
1d1186c901 Replaced markdown preview display iframe with div
No longer need to use the iframe sandboxing techniques, since we now have
CSP rules in-place. Means that embed tags can load without CSP
complications.

Causes slight change to contents of `editor-markdown::setup` editor
event data, since we're changing the `displayEl` property.

Updates markdown editor component to make better use of the component
system.
2022-05-23 15:16:23 +01:00
Dan Brown
641a26cdf7 Updated markdown editor to use svg drawio images
- Also tweaked page editor to not error when the current user does not
  have permission to change editor type.
2022-05-23 14:38:34 +01:00
Dan Brown
5fd8e7e0e9 Updated drawio tinymce plugin to use embeds
- Adds support for handling drawings as embeds, based on image
  extension.
- Adds additional attribute to drawio elements within editor to prevent
  tinymce replacing embeds with a placeholder.
- Updates how contenteditable is applied to drawio blocks within editor,
  to use proper filters instead of using the SetContent event.
2022-05-23 12:24:40 +01:00
Dan Brown
d926ca5f71 Updated draw.io code to support SVGs as primary data type 2022-05-22 12:38:50 +01:00
Dan Brown
b69722c3b5 Fixed issue caused by changing test method defaults 2022-05-22 11:58:22 +01:00
Dan Brown
c9aa1c979f Added SVG support to the image gallery. 2022-05-22 11:52:42 +01:00
17 changed files with 220 additions and 128 deletions

View File

@@ -215,14 +215,16 @@ class ExportFormatter
*/
protected function containHtml(string $htmlContent): string
{
$imageTagsOutput = [];
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
// Replace embed tags with images
$htmlContent = preg_replace("/<embed (.*?)>/i", '<img $1>', $htmlContent);
// Replace image src with base64 encoded image strings
// Replace image & embed src attributes with base64 encoded data strings
$imageTagsOutput = [];
preg_match_all("/<img .*?src=['\"](.*?)['\"].*?>/i", $htmlContent, $imageTagsOutput);
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgTagString = $imgMatch;
$srcString = $imageTagsOutput[2][$index];
$srcString = $imageTagsOutput[1][$index];
$imageEncoded = $this->imageService->imageUriToBase64($srcString);
if ($imageEncoded === null) {
$imageEncoded = $srcString;
@@ -232,14 +234,13 @@ class ExportFormatter
}
}
// Replace any relative links with full system URL
$linksOutput = [];
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
// Replace image src with base64 encoded image strings
preg_match_all("/<a .*href=['\"](.*?)['\"].*?>/i", $htmlContent, $linksOutput);
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
$srcString = $linksOutput[2][$index];
$srcString = $linksOutput[1][$index];
if (strpos(trim($srcString), 'http') !== 0) {
$newSrcString = url($srcString);
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
@@ -248,7 +249,6 @@ class ExportFormatter
}
}
// Replace any relative links with system domain
return $htmlContent;
}

View File

@@ -219,6 +219,6 @@ abstract class Controller extends BaseController
*/
protected function getImageValidationRules(): array
{
return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
return ['image_extension', 'mimes:jpeg,png,gif,webp,svg', 'max:' . (config('app.upload_limit') * 1000)];
}
}

View File

@@ -76,8 +76,11 @@ class DrawioImageController extends Controller
return $this->jsonError('Image data could not be found');
}
$isSvg = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'svg';
$uriPrefix = $isSvg ? 'data:image/svg+xml;base64,' : 'data:image/png;base64,';
return response()->json([
'content' => base64_encode($imageData),
'content' => $uriPrefix . base64_encode($imageData),
]);
}
}

View File

@@ -148,7 +148,8 @@ class ImageRepo
*/
public function saveDrawing(string $base64Uri, int $uploadedTo): Image
{
$name = 'Drawing-' . user()->id . '-' . time() . '.png';
$isSvg = strpos($base64Uri, 'data:image/svg+xml;') === 0;
$name = 'Drawing-' . user()->id . '-' . time() . ($isSvg ? '.svg' : '.png');
return $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo);
}

View File

@@ -30,7 +30,7 @@ class ImageService
protected $image;
protected $fileSystem;
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
/**
* ImageService constructor.
@@ -230,6 +230,14 @@ class ImageService
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image is an SVG image file.
*/
protected function isSvg(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'svg';
}
/**
* Check if the given image and image data is apng.
*/
@@ -255,8 +263,8 @@ class ImageService
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
{
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
// Do not resize GIF images where we're not cropping or SVG images.
if (($keepRatio && $this->isGif($image)) || $this->isSvg($image)) {
return $this->getPublicUrl($image->path);
}

View File

@@ -18,10 +18,8 @@ class MarkdownEditor {
this.markdown = new MarkdownIt({html: true});
this.markdown.use(mdTasksLists, {label: true});
this.display = this.elem.querySelector('.markdown-display');
this.displayStylesLoaded = false;
this.input = this.elem.querySelector('textarea');
this.display = this.$refs.display;
this.input = this.$refs.input;
this.cm = null;
this.Code = null;
@@ -32,23 +30,13 @@ class MarkdownEditor {
});
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
const displayLoad = () => {
this.displayDoc = this.display.contentDocument;
this.init(cmLoadPromise);
};
if (this.display.contentDocument.readyState === 'complete') {
displayLoad();
} else {
this.display.addEventListener('load', displayLoad.bind(this));
}
window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
markdownIt: this.markdown,
displayEl: this.display,
codeMirrorInstance: this.cm,
});
this.init(cmLoadPromise);
}
init(cmLoadPromise) {
@@ -56,17 +44,17 @@ class MarkdownEditor {
let lastClick = 0;
// Prevent markdown display link click redirect
this.displayDoc.addEventListener('click', event => {
let isDblClick = Date.now() - lastClick < 300;
this.display.addEventListener('click', event => {
const isDblClick = Date.now() - lastClick < 300;
let link = event.target.closest('a');
const link = event.target.closest('a');
if (link !== null) {
event.preventDefault();
window.open(link.getAttribute('href'));
return;
}
let drawing = event.target.closest('[drawio-diagram]');
const drawing = event.target.closest('[drawio-diagram]');
if (drawing !== null && isDblClick) {
this.actionEditDrawing(drawing);
return;
@@ -77,10 +65,10 @@ class MarkdownEditor {
// Button actions
this.elem.addEventListener('click', event => {
let button = event.target.closest('button[data-action]');
const button = event.target.closest('button[data-action]');
if (button === null) return;
let action = button.getAttribute('data-action');
const action = button.getAttribute('data-action');
if (action === 'insertImage') this.actionInsertImage();
if (action === 'insertLink') this.actionShowLinkSelector();
if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
@@ -132,35 +120,11 @@ class MarkdownEditor {
window.$events.emit('editor-markdown-change', content);
// Set body content
this.displayDoc.body.className = 'page-content';
this.displayDoc.body.innerHTML = html;
// Copy styles from page head and set custom styles for editor
this.loadStylesIntoDisplay();
}
loadStylesIntoDisplay() {
if (this.displayStylesLoaded) return;
this.displayDoc.documentElement.classList.add('markdown-editor-display');
// Set display to be dark mode if parent is
if (document.documentElement.classList.contains('dark-mode')) {
this.displayDoc.documentElement.style.backgroundColor = '#222';
this.displayDoc.documentElement.classList.add('dark-mode');
}
this.displayDoc.head.innerHTML = '';
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
for (let style of styles) {
const copy = style.cloneNode(true);
this.displayDoc.head.appendChild(copy);
}
this.displayStylesLoaded = true;
this.display.innerHTML = html;
}
onMarkdownScroll(lineCount) {
const elems = this.displayDoc.body.children;
const elems = this.display.children;
if (elems.length <= lineCount) return;
const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
@@ -317,7 +281,7 @@ class MarkdownEditor {
let cursor = cm.getCursor();
let lineContent = cm.getLine(cursor.line);
let lineLen = lineContent.length;
let newLineContent = lineContent;
let newLineContent;
if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
@@ -333,9 +297,9 @@ class MarkdownEditor {
let selection = cm.getSelection();
if (selection === '') return wrapLine(start, end);
let newSelection = selection;
let newSelection;
let frontDiff = 0;
let endDiff = 0;
let endDiff;
if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
newSelection = selection.slice(start.length, selection.length - end.length);
@@ -445,10 +409,10 @@ class MarkdownEditor {
DrawIO.show(url,() => {
return Promise.resolve('');
}, (pngData) => {
}, (drawingData) => {
const data = {
image: pngData,
image: drawingData,
uploaded_to: Number(this.pageId),
};
@@ -462,7 +426,7 @@ class MarkdownEditor {
}
insertDrawing(image, originalCursor) {
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
const newText = DrawIO.buildDrawingContentHtml(image);
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
@@ -480,21 +444,22 @@ class MarkdownEditor {
DrawIO.show(drawioUrl, () => {
return DrawIO.load(drawingId);
}, (pngData) => {
}, (drawingData) => {
let data = {
image: pngData,
image: drawingData,
uploaded_to: Number(this.pageId),
};
window.$http.post("/images/drawio", data).then(resp => {
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
let newContent = this.cm.getValue().split('\n').map(line => {
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
return newText;
}
return line;
const image = resp.data;
const newText = DrawIO.buildDrawingContentHtml(image);
const newContent = this.cm.getValue().split('\n').map(line => {
const isDrawing = line.includes(`drawio-diagram="${drawingId}"`);
return isDrawing ? newText : line;
}).join('\n');
this.cm.setValue(newContent);
this.cm.setCursor(cursorPos);
this.cm.focus();

View File

@@ -24,7 +24,7 @@ class PageEditor {
this.draftDisplayIcon = this.$refs.draftDisplayIcon;
this.changelogInput = this.$refs.changelogInput;
this.changelogDisplay = this.$refs.changelogDisplay;
this.changeEditorButtons = this.$manyRefs.changeEditor;
this.changeEditorButtons = this.$manyRefs.changeEditor || [];
this.switchDialogContainer = this.$refs.switchDialog;
// Translations

View File

@@ -55,7 +55,7 @@ function drawEventExport(message) {
}
function drawEventSave(message) {
drawPostMessage({action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing'});
drawPostMessage({action: 'export', format: 'xmlsvg', xml: message.xml, spin: 'Updating drawing'});
}
function drawEventInit() {
@@ -96,7 +96,21 @@ async function upload(imageData, pageUploadedToId) {
*/
async function load(drawingId) {
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
return `data:image/png;base64,${resp.data.content}`;
return resp.data.content;
}
export default {show, close, upload, load};
function buildDrawingContentHtml(drawing) {
const isSvg = drawing.url.split('.').pop().toLowerCase() === 'svg';
const image = `<img src="${drawing.url}">`;
const embed = `<embed src="${drawing.url}" type="image/svg+xml">`;
return `<div drawio-diagram="${drawing.id}">${isSvg ? embed : image}</div>`
}
function buildDrawingContentNode(drawing) {
const div = document.createElement('div');
div.innerHTML = buildDrawingContentHtml(drawing);
return div.children[0];
}
export default {show, close, upload, load, buildDrawingContentHtml, buildDrawingContentNode};

View File

@@ -1,4 +1,5 @@
import DrawIO from "../services/drawio";
import {build} from "./config";
let pageEditor = null;
let currentNode = null;
@@ -15,15 +16,14 @@ function isDrawing(node) {
function showDrawingManager(mceEditor, selectedNode = null) {
pageEditor = mceEditor;
currentNode = selectedNode;
// Show image manager
window.ImageManager.show(function (image) {
if (selectedNode) {
let imgElem = selectedNode.querySelector('img');
pageEditor.dom.setAttrib(imgElem, 'src', image.url);
pageEditor.dom.setAttrib(selectedNode, 'drawio-diagram', image.id);
pageEditor.dom.replace(buildDrawingNode(image), selectedNode);
} else {
let imgHTML = `<div drawio-diagram="${image.id}" contenteditable="false"><img src="${image.url}"></div>`;
pageEditor.insertContent(imgHTML);
const drawingHtml = DrawIO.buildDrawingContentHtml(image);
pageEditor.insertContent(drawingHtml);
}
}, 'drawio');
}
@@ -34,7 +34,14 @@ function showDrawingEditor(mceEditor, selectedNode = null) {
DrawIO.show(options.drawioUrl, drawingInit, updateContent);
}
async function updateContent(pngData) {
function buildDrawingNode(drawing) {
const drawingEl = DrawIO.buildDrawingContentNode(drawing);
drawingEl.setAttribute('contenteditable', 'false');
drawingEl.setAttribute('data-ephox-embed-iri', 'true');
return drawingEl;
}
async function updateContent(drawingData) {
const id = "image-" + Math.random().toString(16).slice(2);
const loadingImage = window.baseUrl('/loading.gif');
@@ -50,11 +57,9 @@ async function updateContent(pngData) {
// Handle updating an existing image
if (currentNode) {
DrawIO.close();
let imgElem = currentNode.querySelector('img');
try {
const img = await DrawIO.upload(pngData, options.pageId);
pageEditor.dom.setAttrib(imgElem, 'src', img.url);
pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
const img = await DrawIO.upload(drawingData, options.pageId);
pageEditor.dom.replace(buildDrawingNode(img), currentNode);
} catch (err) {
handleUploadError(err);
}
@@ -62,12 +67,11 @@ async function updateContent(pngData) {
}
setTimeout(async () => {
pageEditor.insertContent(`<div drawio-diagram contenteditable="false"><img src="${loadingImage}" id="${id}"></div>`);
pageEditor.insertContent(`<div drawio-diagram contenteditable="false"><img src="${loadingImage}" alt="Loading" id="${id}"></div>`);
DrawIO.close();
try {
const img = await DrawIO.upload(pngData, options.pageId);
pageEditor.dom.setAttrib(id, 'src', img.url);
pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
const img = await DrawIO.upload(drawingData, options.pageId);
pageEditor.dom.replace(buildDrawingNode(img), pageEditor.dom.get(id).parentNode);
} catch (err) {
pageEditor.dom.remove(id);
handleUploadError(err);
@@ -86,7 +90,6 @@ function drawingInit() {
}
/**
*
* @param {WysiwygConfigOptions} providedOptions
* @return {function(Editor, string)}
*/
@@ -130,14 +133,28 @@ export function getPlugin(providedOptions) {
showDrawingEditor(editor, selectedNode);
});
editor.on('SetContent', function () {
const drawings = editor.$('body > div[drawio-diagram]');
if (!drawings.length) return;
editor.on('PreInit', () => {
editor.parser.addNodeFilter('div', function(nodes) {
for (const node of nodes) {
if (node.attr('drawio-diagram')) {
// Set content editable to be false to prevent direct editing of child content.
node.attr('contenteditable', 'false');
// Set this attribute to prevent drawing contents being parsed as media embeds
// to avoid contents being replaced with placeholder images.
// TinyMCE embed plugin sources looks for this attribute in its logic.
node.attr('data-ephox-embed-iri', 'true');
}
}
});
editor.undoManager.transact(function () {
drawings.each((index, elem) => {
elem.setAttribute('contenteditable', 'false');
});
editor.serializer.addNodeFilter('div', function(nodes) {
for (const node of nodes) {
// Clean up content attributes
if (node.attr('drawio-diagram')) {
node.attr('contenteditable', null);
node.attr('data-ephox-embed-iri', null);
}
}
});
});

View File

@@ -114,26 +114,20 @@
.markdown-display {
margin-inline-start: -1px;
}
.markdown-editor-display {
display: block;
background-color: #fff;
body {
display: block;
background-color: #fff;
padding-inline-start: 16px;
padding-inline-end: 16px;
}
padding: $-m;
overflow-y: scroll;
[drawio-diagram]:hover {
outline: 2px solid var(--color-primary);
}
[drawio-diagram] embed {
pointer-events: none;
}
}
html.markdown-editor-display.dark-mode {
.dark-mode .markdown-display {
background-color: #222;
body {
background-color: #222;
}
}
.editor-toolbar {

View File

@@ -48,6 +48,11 @@ body.page-content.mce-content-body {
display: none;
}
// Prevent interaction with embed contents
.page-content.mce-content-body embed {
pointer-events: none;
}
// Details/summary editor usability
.page-content.mce-content-body details summary {
pointer-events: none;

View File

@@ -23,6 +23,7 @@
<div markdown-input class="flex flex-fill">
<textarea id="markdown-editor-input"
refs="markdown-editor@input"
@if($errors->has('markdown')) class="text-neg" @endif
name="markdown"
rows="5">@if(isset($model) || old('markdown')){{ old('markdown') ?? ($model->markdown === '' ? $model->html : $model->markdown) }}@endif</textarea>
@@ -34,7 +35,10 @@
<div class="editor-toolbar">
<div class="editor-toolbar-label">{{ trans('entities.pages_md_preview') }}</div>
</div>
<iframe src="about:blank" class="markdown-display" sandbox="allow-same-origin"></iframe>
<div class="markdown-display">
<div refs="markdown-editor@display"
class="page-content"></div>
</div>
</div>
</div>

View File

@@ -258,6 +258,24 @@ class ExportTest extends TestCase
unlink($testFilePath);
}
public function test_page_export_contained_html_embed_elements_are_converted_to_images_with_srcs_inlined()
{
$page = Page::query()->first();
$page->html = '<embed src="http://localhost/uploads/images/gallery/svg_test.svg"/>';
$page->save();
$storageDisk = Storage::disk('local');
$storageDisk->makeDirectory('uploads/images/gallery');
$storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
$resp = $this->asEditor()->get($page->getUrl('/export/html'));
$storageDisk->delete('uploads/images/gallery/svg_test.svg');
$resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false);
$resp->assertSee('<img src="data:image/svg+xml;base64,PHN2Zz5nb29kPC9zdmc+">', false);
}
public function test_exports_removes_scripts_from_custom_head()
{
$entities = [

View File

@@ -10,7 +10,7 @@ class DrawioTest extends TestCase
{
use UsesImages;
public function test_get_image_as_base64()
public function test_get_image_as_base64_with_png_content()
{
$page = Page::first();
$this->asAdmin();
@@ -23,11 +23,27 @@ class DrawioTest extends TestCase
$imageGet = $this->getJson("/images/drawio/base64/{$image->id}");
$imageGet->assertJson([
'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
'content' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
]);
}
public function test_drawing_base64_upload()
public function test_get_image_as_base64_with_svg_content()
{
$page = Page::first();
$this->asAdmin();
$this->uploadImage('my-drawing.svg', $page->id, 'image/svg+xml', 'diagram.svg');
$image = Image::first();
$image->type = 'drawio';
$image->save();
$imageGet = $this->getJson("/images/drawio/base64/{$image->id}");
$imageGet->assertJson([
'content' => 'data:image/svg+xml;base64,' . base64_encode(file_get_contents($this->getTestImageFilePath('diagram.svg'))),
]);
}
public function test_drawing_base64_upload_with_png()
{
$page = Page::first();
$editor = $this->getEditor();
@@ -35,7 +51,7 @@ class DrawioTest extends TestCase
$upload = $this->postJson('images/drawio', [
'uploaded_to' => $page->id,
'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
'image' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
]);
$upload->assertStatus(200);
@@ -54,6 +70,34 @@ class DrawioTest extends TestCase
$this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected');
}
public function test_drawing_base64_upload_with_svg()
{
$page = Page::first();
$editor = $this->getEditor();
$this->actingAs($editor);
$upload = $this->postJson('images/drawio', [
'uploaded_to' => $page->id,
'image' => 'data:image/svg+xml;base64,' . base64_encode(file_get_contents($this->getTestImageFilePath('diagram.svg'))),
]);
$upload->assertStatus(200);
$upload->assertJson([
'type' => 'drawio',
'uploaded_to' => $page->id,
'created_by' => $editor->id,
'updated_by' => $editor->id,
]);
$image = Image::where('type', '=', 'drawio')->first();
$this->assertStringEndsWith('.svg', $image->path);
$this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: ' . public_path($image->path));
$testImageData = file_get_contents($this->getTestImageFilePath('diagram.svg'));
$uploadedImageData = file_get_contents(public_path($image->path));
$this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected');
}
public function test_drawio_url_can_be_configured()
{
config()->set('services.drawio', 'http://cats.com?dog=tree');

View File

@@ -74,6 +74,23 @@ class ImageTest extends TestCase
$this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
}
public function test_svg_upload()
{
/** @var Page $page */
$page = Page::query()->first();
$admin = $this->getAdmin();
$this->actingAs($admin);
$imgDetails = $this->uploadGalleryImage($page, 'diagram.svg', 'image/svg+xml');
$this->assertFileExists(public_path($imgDetails['path']));
$this->assertTrue(
$imgDetails['response']->url === $imgDetails['response']->thumbs->gallery
&& $imgDetails['response']->url === $imgDetails['response']->thumbs->display,
);
$this->deleteImage($imgDetails['path']);
}
public function test_image_edit()
{
$editor = $this->getEditor();

View File

@@ -3,6 +3,7 @@
namespace Tests\Uploads;
use BookStack\Entities\Models\Page;
use BookStack\Uploads\Image;
use Illuminate\Http\UploadedFile;
use stdClass;
@@ -39,9 +40,9 @@ trait UsesImages
/**
* Get a test image that can be uploaded.
*/
protected function getTestImage(string $fileName, ?string $testDataFileName = null): UploadedFile
protected function getTestImage(string $fileName, ?string $testDataFileName = null, $mimeType = 'image/png'): UploadedFile
{
return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', null, true);
return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, $mimeType, null, true);
}
/**
@@ -73,7 +74,7 @@ trait UsesImages
*/
protected function uploadImage($name, $uploadedTo = 0, $contentType = 'image/png', ?string $testDataFileName = null)
{
$file = $this->getTestImage($name, $testDataFileName);
$file = $this->getTestImage($name, $testDataFileName, $contentType);
return $this->withHeader('Content-Type', $contentType)
->call('POST', '/images/gallery', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
@@ -84,11 +85,9 @@ trait UsesImages
* Returns the image name.
* Can provide a page to relate the image to.
*
* @param Page|null $page
*
* @return array{name: string, path: string, page: Page, response: stdClass}
*/
protected function uploadGalleryImage(Page $page = null, ?string $testDataFileName = null)
protected function uploadGalleryImage(Page $page = null, string $testDataFileName = null, string $contentType = 'image/png')
{
if ($page === null) {
$page = Page::query()->first();
@@ -98,7 +97,7 @@ trait UsesImages
$relPath = $this->getTestImagePath('gallery', $imageName);
$this->deleteImage($relPath);
$upload = $this->uploadImage($imageName, $page->id, 'image/png', $testDataFileName);
$upload = $this->uploadImage($imageName, $page->id, $contentType, $testDataFileName);
$upload->assertStatus(200);
return [

View File

@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(255, 255, 255);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="121px" height="141px" viewBox="-0.5 -0.5 121 141"><defs/><g><ellipse cx="25" cy="87.5" rx="7.5" ry="7.5" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/><path d="M 25 95 L 25 120 M 25 100 L 10 100 M 25 100 L 40 100 M 25 120 L 10 140 M 25 120 L 40 140" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/><path d="M 0 0 L 120 0 L 120 50 L 80 50 L 60 80 L 60 50 L 0 50 Z" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 25px; margin-left: 1px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;" data-drawio-colors="color: rgb(0, 0, 0); "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">Hello!</div></div></div></foreignObject><text x="60" y="29" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">Hello!</text></switch></g></g><switch><g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/><a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"><text text-anchor="middle" font-size="10px" x="50%" y="100%">Text is not SVG - cannot display</text></a></switch></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB