Controller for Icon metadata
closes MAT-902 flag=none Test plan - Having an icon attachment id access /api/v1/files/6610/icon_metadata and ensure you can get the icon metadata - In the Rich Content Editor, ensure you can create and edit icons successfully using the metadata endpoint Change-Id: I44ddc1d9187e2e1ce8fd194f6aa4826047c10e3f Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/297548 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Jeremy Stanley <jeremy@instructure.com> Reviewed-by: Jacob DeWar <jacob.dewar@instructure.com> QA-Review: Jacob DeWar <jacob.dewar@instructure.com> Product-Review: Mysti Lilla <mysti@instructure.com>
This commit is contained in:
parent
b1acfec0dd
commit
5205c27ed6
|
@ -136,7 +136,8 @@ class FilesController < ApplicationController
|
|||
before_action :require_context, except: %i[
|
||||
assessment_question_show image_thumbnail show_thumbnail
|
||||
create_pending s3_success show api_create api_create_success api_create_success_cors
|
||||
api_show api_index destroy api_update api_file_status public_url api_capture reset_verifier
|
||||
api_show api_index destroy api_update api_file_status public_url api_capture icon_metadata
|
||||
reset_verifier
|
||||
]
|
||||
|
||||
before_action :open_limited_cors, only: [:show]
|
||||
|
@ -148,7 +149,7 @@ class FilesController < ApplicationController
|
|||
|
||||
skip_before_action :verify_authenticity_token, only: :api_create
|
||||
before_action :verify_api_id, only: %i[
|
||||
api_show api_create_success api_file_status api_update destroy reset_verifier
|
||||
api_show api_create_success api_file_status api_update destroy icon_metadata reset_verifier
|
||||
]
|
||||
|
||||
include Api::V1::Attachment
|
||||
|
@ -497,7 +498,7 @@ class FilesController < ApplicationController
|
|||
# file was deleted and replaced by another.
|
||||
#
|
||||
# Indicates the context ID Canvas should use when following the "replacement chain." The
|
||||
# "replacement_chain_context_id" paraamter must also be included.
|
||||
# "replacement_chain_context_type" parameter must also be included.
|
||||
#
|
||||
# @example_request
|
||||
#
|
||||
|
@ -524,7 +525,7 @@ class FilesController < ApplicationController
|
|||
end
|
||||
|
||||
params[:include] = Array(params[:include])
|
||||
if read_allowed(@attachment, @current_user, session, params)
|
||||
if access_allowed(@attachment, @current_user, :read, session, params)
|
||||
json = attachment_json(@attachment, @current_user, {}, { include: params[:include], omit_verifier_in_app: !value_to_boolean(params[:use_verifiers]) })
|
||||
|
||||
# Add canvadoc session URL if the file is unlocked
|
||||
|
@ -603,7 +604,7 @@ class FilesController < ApplicationController
|
|||
return
|
||||
end
|
||||
|
||||
if read_allowed(@attachment, @current_user, session, params)
|
||||
if access_allowed(@attachment, @current_user, :read, session, params)
|
||||
@attachment.ensure_media_object
|
||||
verifier_checker = Attachments::Verification.new(@attachment)
|
||||
|
||||
|
@ -1308,6 +1309,86 @@ class FilesController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
# @API Get icon metadata
|
||||
# Returns the icon maker file attachment metadata
|
||||
#
|
||||
# @example_request
|
||||
#
|
||||
# curl 'https://<canvas>/api/v1/courses/1/files/1/metadata' \
|
||||
# -H 'Authorization: Bearer <token>'
|
||||
#
|
||||
# @example_response
|
||||
#
|
||||
# {
|
||||
# "type":"image/svg+xml-icon-maker-icons",
|
||||
# "alt":"",
|
||||
# "shape":"square",
|
||||
# "size":"small",
|
||||
# "color":"#FFFFFF",
|
||||
# "outlineColor":"#65499D",
|
||||
# "outlineSize":"large",
|
||||
# "text":"Hello",
|
||||
# "textSize":"x-large",
|
||||
# "textColor":"#65499D",
|
||||
# "textBackgroundColor":"#FFFFFF",
|
||||
# "textPosition":"bottom-third",
|
||||
# "encodedImage":"data:image/svg+xml;base64,PH==",
|
||||
# "encodedImageType":"SingleColor",
|
||||
# "encodedImageName":"Health Icon",
|
||||
# "x":"50%",
|
||||
# "y":"50%",
|
||||
# "translateX":-54,
|
||||
# "translateY":-54,
|
||||
# "width":108,
|
||||
# "height":108,
|
||||
# "transform":"translate(-54,-54)"
|
||||
# }
|
||||
#
|
||||
def icon_metadata
|
||||
@icon = Attachment.find(params[:id])
|
||||
@icon = attachment_or_replacement(@icon.context, params[:id]) if @icon.deleted? && @icon.replacement_attachment_id.present?
|
||||
return render json: { errors: [{ message: "The specified resource does not exist." }] }, status: :not_found if @icon.deleted?
|
||||
return unless access_allowed(@icon, @current_user, :download, session, params)
|
||||
|
||||
unless @icon.category == Attachment::ICON_MAKER_ICONS
|
||||
return render json: { errors: [{ message: "The requested attachment does not support viewing metadata." }] }, status: :bad_request
|
||||
end
|
||||
|
||||
sax_doc = MetadataSaxDoc.new
|
||||
parser = Nokogiri::XML::SAX::PushParser.new(sax_doc)
|
||||
@icon.open do |chunk|
|
||||
parser << chunk
|
||||
break if sax_doc.metadata_value.present?
|
||||
end
|
||||
sax_doc.metadata_value.present? ? render(json: { name: @icon.display_name }.merge(JSON.parse(sax_doc.metadata_value))) : head(:no_content)
|
||||
end
|
||||
|
||||
class MetadataSaxDoc < Nokogiri::XML::SAX::Document
|
||||
attr_reader :current_value, :metadata_value, :retain_data
|
||||
|
||||
def start_element(name, _attrs)
|
||||
return unless name == "metadata"
|
||||
|
||||
@current_value = ""
|
||||
@retain_data = true
|
||||
end
|
||||
|
||||
def end_element(name)
|
||||
return unless name == "metadata"
|
||||
|
||||
@metadata_value = current_value
|
||||
@retain_data = false
|
||||
end
|
||||
|
||||
def characters(chars)
|
||||
return unless retain_data
|
||||
|
||||
@current_value ||= ""
|
||||
@current_value += chars
|
||||
end
|
||||
end
|
||||
private_constant :MetadataSaxDoc
|
||||
|
||||
# @API Reset link verifier
|
||||
#
|
||||
# Resets the link verifier. Any existing links to the file using
|
||||
|
@ -1426,19 +1507,19 @@ class FilesController < ApplicationController
|
|||
headers["Access-Control-Allow-Methods"] = "GET, HEAD"
|
||||
end
|
||||
|
||||
def read_allowed(attachment, user, session, params)
|
||||
def access_allowed(attachment, user, access_type, session, params)
|
||||
if params[:verifier]
|
||||
verifier_checker = Attachments::Verification.new(attachment)
|
||||
return true if verifier_checker.valid_verifier_for_permission?(params[:verifier], :read, session)
|
||||
return true if verifier_checker.valid_verifier_for_permission?(params[:verifier], access_type, session)
|
||||
end
|
||||
|
||||
submissions = attachment.attachment_associations.where(context_type: "Submission").preload(:context).filter_map(&:context)
|
||||
return true if submissions.any? { |submission| submission.grants_right?(user, session, :read) }
|
||||
return true if submissions.any? { |submission| submission.grants_right?(user, session, access_type) }
|
||||
|
||||
course = api_find(Assignment, params[:assignment_id]).course unless params[:assignment_id].nil?
|
||||
return true if course&.grants_right?(user, session, :read)
|
||||
course = api_find(Assignment, params[:assignment_id]).course if params[:assignment_id].present?
|
||||
return true if course&.grants_right?(user, session, access_type)
|
||||
|
||||
authorized_action(attachment, user, :read)
|
||||
authorized_action(attachment, user, access_type)
|
||||
end
|
||||
|
||||
def strong_attachment_params
|
||||
|
|
|
@ -161,7 +161,7 @@ class Attachment < ActiveRecord::Base
|
|||
after_save_and_attachment_processing :ensure_media_object
|
||||
|
||||
# this mixin can be added to a has_many :attachments association, and it'll
|
||||
# handle finding replaced attachments. In other words, if an attachment fond
|
||||
# handle finding replaced attachments. In other words, if an attachment found
|
||||
# by id is deleted but an active attachment in the same context has the same
|
||||
# path, it'll return that attachment.
|
||||
module FindInContextAssociation
|
||||
|
|
|
@ -1766,6 +1766,7 @@ CanvasRails::Application.routes.draw do
|
|||
|
||||
# 'attachment' (rather than 'file') is used below so modules API can use polymorphic_url to generate an item API link
|
||||
get "files/:id", action: :api_show, as: "attachment"
|
||||
get "files/:id/icon_metadata", action: :icon_metadata
|
||||
delete "files/:id", action: :destroy
|
||||
put "files/:id", action: :api_update
|
||||
post "files/:id/reset_verifier", action: :reset_verifier
|
||||
|
|
|
@ -158,6 +158,7 @@
|
|||
"@testing-library/dom": "^8",
|
||||
"@testing-library/jest-dom": "^5",
|
||||
"@testing-library/react": "^12",
|
||||
"@testing-library/react-hooks": "^5",
|
||||
"@testing-library/user-event": "^12",
|
||||
"axe-testcafe": "^3",
|
||||
"babel-loader": "^8.0.0",
|
||||
|
|
|
@ -85,7 +85,9 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async() => { jest.runOnlyPendingTimers() })
|
||||
await act(async () => {
|
||||
jest.runOnlyPendingTimers()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the create view', () => {
|
||||
|
@ -140,7 +142,7 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
})
|
||||
|
||||
beforeEach(() => {
|
||||
window.HTMLElement.prototype.focus = jest.fn().mockImplementation(function (args) {
|
||||
window.HTMLElement.prototype.focus = jest.fn().mockImplementation(function (_args) {
|
||||
focusedElement = this
|
||||
})
|
||||
})
|
||||
|
@ -442,24 +444,20 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
|
|||
beforeEach(() => {
|
||||
fetchMock.mock('*', {
|
||||
body: `
|
||||
<svg height="100" width="100">
|
||||
<metadata>
|
||||
{
|
||||
"alt":"a test image",
|
||||
"shape":"triangle",
|
||||
"size":"large",
|
||||
"color":"#FF2717",
|
||||
"outlineColor":"#06A3B7",
|
||||
"outlineSize":"small",
|
||||
"text":"Some Text",
|
||||
"textSize":"medium",
|
||||
"textColor":"#009606",
|
||||
"textBackgroundColor":"#E71F63",
|
||||
"textPosition":"below"
|
||||
}
|
||||
</metadata>
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
|
||||
</svg>`
|
||||
{
|
||||
"name":"Test Icon.svg",
|
||||
"alt":"a test image",
|
||||
"shape":"triangle",
|
||||
"size":"large",
|
||||
"color":"#FF2717",
|
||||
"outlineColor":"#06A3B7",
|
||||
"outlineSize":"small",
|
||||
"text":"Some Text",
|
||||
"textSize":"medium",
|
||||
"textColor":"#009606",
|
||||
"textBackgroundColor":"#E71F63",
|
||||
"textPosition":"below"
|
||||
}`
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -18,11 +18,8 @@
|
|||
|
||||
import fetchMock from 'fetch-mock'
|
||||
import {renderHook, act} from '@testing-library/react-hooks/dom'
|
||||
import {useSvgSettings, svgFromUrl, statuses} from '../settings'
|
||||
import {useSvgSettings, statuses} from '../settings'
|
||||
import Editor from '../../../shared/__tests__/FakeEditor'
|
||||
import RceApiSource from '../../../../../rcs/api'
|
||||
|
||||
jest.mock('../../../../../rcs/api')
|
||||
|
||||
describe('useSvgSettings()', () => {
|
||||
let editing, ed, rcs
|
||||
|
@ -30,16 +27,12 @@ describe('useSvgSettings()', () => {
|
|||
beforeEach(() => {
|
||||
ed = new Editor()
|
||||
rcs = {
|
||||
getFile: jest.fn(() => Promise.resolve({name: 'Test Icon.svg'})),
|
||||
contextType: 'course',
|
||||
contextId: 1,
|
||||
canvasUrl: 'https://domain.from.env'
|
||||
}
|
||||
RceApiSource.mockImplementation(() => rcs)
|
||||
})
|
||||
|
||||
afterEach(() => RceApiSource.mockClear())
|
||||
|
||||
const subject = () => renderHook(() => useSvgSettings(ed, editing, rcs)).result
|
||||
|
||||
describe('when a new icon is being created (not editing)', () => {
|
||||
|
@ -166,10 +159,8 @@ describe('useSvgSettings()', () => {
|
|||
// Icon Maker icons that have it
|
||||
//
|
||||
body = `
|
||||
<svg height="100" width="100">
|
||||
<metadata>
|
||||
{
|
||||
"name":"Test Image",
|
||||
"name":"Test Icon",
|
||||
"alt":"a test image",
|
||||
"shape":"triangle",
|
||||
"size":"large",
|
||||
|
@ -181,14 +172,11 @@ describe('useSvgSettings()', () => {
|
|||
"textColor":"#009606",
|
||||
"textBackgroundColor":"#06A3B7",
|
||||
"textPosition":"below"
|
||||
}
|
||||
</metadata>
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
|
||||
</svg>`
|
||||
}`
|
||||
|
||||
// Stub fetch to return an SVG file
|
||||
mock = fetchMock.mock({
|
||||
name: 'download-url',
|
||||
name: 'icon_metadata',
|
||||
matcher: '*',
|
||||
response: () => ({body})
|
||||
})
|
||||
|
@ -199,12 +187,12 @@ describe('useSvgSettings()', () => {
|
|||
fetchMock.restore()
|
||||
})
|
||||
|
||||
it('fetches the SVG file, specifying the course ID and timestamp', () => {
|
||||
it('fetches the icon metadata, specifying the course ID and timestamp', () => {
|
||||
subject()
|
||||
|
||||
expect(mock.called('download-url')).toBe(true)
|
||||
expect(mock.calls('download-url')[0][0]).toMatch(
|
||||
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
|
||||
expect(mock.called('icon_metadata')).toBe(true)
|
||||
expect(mock.calls('icon_metadata')[0][0]).toMatch(
|
||||
/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -216,12 +204,12 @@ describe('useSvgSettings()', () => {
|
|||
ed.setSelectedNode(ed.dom.select('#test-image')[0])
|
||||
})
|
||||
|
||||
it('fetches the SVG file using the /files/:file_id/download endpoint', () => {
|
||||
it('fetches the icon metadata using the /files/:file_id/icon_metadata endpoint', () => {
|
||||
subject()
|
||||
|
||||
expect(mock.called('download-url')).toBe(true)
|
||||
expect(mock.calls('download-url')[0][0]).toMatch(
|
||||
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
|
||||
expect(mock.called('icon_metadata')).toBe(true)
|
||||
expect(mock.calls('icon_metadata')[0][0]).toMatch(
|
||||
/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -234,12 +222,10 @@ describe('useSvgSettings()', () => {
|
|||
ed.setSelectedNode(ed.dom.select('#test-image')[0])
|
||||
})
|
||||
|
||||
it('fetches the SVG file, specifying the course ID and timestamp', () => {
|
||||
it('fetches the icon metadata, specifying the course ID and timestamp', () => {
|
||||
subject()
|
||||
const calledUrl = mock.calls('download-url')[0][0]
|
||||
expect(calledUrl).toMatch(
|
||||
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
|
||||
)
|
||||
const calledUrl = mock.calls('icon_metadata')[0][0]
|
||||
expect(calledUrl).toMatch(/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -251,29 +237,14 @@ describe('useSvgSettings()', () => {
|
|||
ed.setSelectedNode(ed.dom.select('#containing')[0])
|
||||
})
|
||||
|
||||
it('fetches the SVG file, specifying the course ID and timestamp', () => {
|
||||
it('fetches the icon metadata, specifying the course ID and timestamp', () => {
|
||||
subject()
|
||||
const calledUrl = mock.calls('download-url')[0][0]
|
||||
expect(calledUrl).toMatch(
|
||||
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
|
||||
)
|
||||
const calledUrl = mock.calls('icon_metadata')[0][0]
|
||||
expect(calledUrl).toMatch(/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses replacement chain context info in request for file name', async () => {
|
||||
const {result, waitForValueToChange} = renderHook(() => useSvgSettings(ed, editing, rcs))
|
||||
|
||||
await waitForValueToChange(() => {
|
||||
return result.current[0]
|
||||
})
|
||||
|
||||
expect(rcs.getFile).toHaveBeenCalledWith('1', {
|
||||
replacement_chain_context_id: 1,
|
||||
replacement_chain_context_type: 'course'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses the SVG settings from the SVG metadata', async () => {
|
||||
it('parses the SVG settings from the icon metadata', async () => {
|
||||
const {result, waitForValueToChange} = renderHook(() => useSvgSettings(ed, editing, rcs))
|
||||
|
||||
await waitForValueToChange(() => {
|
||||
|
@ -317,41 +288,36 @@ describe('useSvgSettings()', () => {
|
|||
|
||||
describe('parses the SVG settings from a legacy SVG metadata structure', () => {
|
||||
const bodyGenerator = overrideParams => `
|
||||
<svg height="100" width="100">
|
||||
<metadata>
|
||||
${JSON.stringify({
|
||||
...{
|
||||
name: 'Test Image',
|
||||
alt: 'a test image',
|
||||
shape: 'triangle',
|
||||
size: 'large',
|
||||
color: '#FF2717',
|
||||
outlineColor: '#06A3B7',
|
||||
outlineSize: 'small',
|
||||
text: 'Some Text',
|
||||
textSize: 'medium',
|
||||
textColor: '#009606',
|
||||
textBackgroundColor: '#06A3B7',
|
||||
textPosition: 'below',
|
||||
imageSettings: {
|
||||
cropperSettings: null,
|
||||
icon: {
|
||||
label: 'Art Icon'
|
||||
},
|
||||
iconFillColor: '#FFFFFF',
|
||||
image: 'Art Icon',
|
||||
mode: 'SingleColor'
|
||||
}
|
||||
${JSON.stringify({
|
||||
...{
|
||||
name: 'Test Icon',
|
||||
alt: 'a test image',
|
||||
shape: 'triangle',
|
||||
size: 'large',
|
||||
color: '#FF2717',
|
||||
outlineColor: '#06A3B7',
|
||||
outlineSize: 'small',
|
||||
text: 'Some Text',
|
||||
textSize: 'medium',
|
||||
textColor: '#009606',
|
||||
textBackgroundColor: '#06A3B7',
|
||||
textPosition: 'below',
|
||||
imageSettings: {
|
||||
cropperSettings: null,
|
||||
icon: {
|
||||
label: 'Art Icon'
|
||||
},
|
||||
...overrideParams
|
||||
})}
|
||||
</metadata>
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
|
||||
</svg>`
|
||||
iconFillColor: '#FFFFFF',
|
||||
image: 'Art Icon',
|
||||
mode: 'SingleColor'
|
||||
}
|
||||
},
|
||||
...overrideParams
|
||||
})}`
|
||||
|
||||
const overwriteUrl = () =>
|
||||
(mock = fetchMock.mock({
|
||||
name: 'download-url',
|
||||
name: 'icon_metadata',
|
||||
matcher: '*',
|
||||
response: () => ({body}),
|
||||
overwriteRoutes: true
|
||||
|
@ -642,48 +608,40 @@ describe('useSvgSettings()', () => {
|
|||
data-download-url="https://canvas.instructure.com/files/2/download" />
|
||||
`)
|
||||
|
||||
fetchMock.mock('begin:https://domain.from.env/files/1/download', {
|
||||
fetchMock.mock('begin:https://domain.from.env/api/v1/files/1/icon_metadata', {
|
||||
body: `
|
||||
<svg height="100" width="100">
|
||||
<metadata>
|
||||
{
|
||||
"alt":"the first test image",
|
||||
"shape":"triangle",
|
||||
"size":"large",
|
||||
"color":"#FF2717",
|
||||
"outlineColor":"#06A3B7",
|
||||
"outlineSize":"small",
|
||||
"text":"Some Text",
|
||||
"textSize":"medium",
|
||||
"textColor":"#009606",
|
||||
"textBackgroundColor":"#06A3B7",
|
||||
"textPosition":"below"
|
||||
}
|
||||
</metadata>
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
|
||||
</svg>`
|
||||
{
|
||||
"name":"Test Icon.svg",
|
||||
"alt":"the first test image",
|
||||
"shape":"triangle",
|
||||
"size":"large",
|
||||
"color":"#FF2717",
|
||||
"outlineColor":"#06A3B7",
|
||||
"outlineSize":"small",
|
||||
"text":"Some Text",
|
||||
"textSize":"medium",
|
||||
"textColor":"#009606",
|
||||
"textBackgroundColor":"#06A3B7",
|
||||
"textPosition":"below"
|
||||
}`
|
||||
})
|
||||
|
||||
fetchMock.mock('begin:https://domain.from.env/files/2/download', {
|
||||
fetchMock.mock('begin:https://domain.from.env/api/v1/files/2/icon_metadata', {
|
||||
body: `
|
||||
<svg height="100" width="100">
|
||||
<metadata>
|
||||
{
|
||||
"alt":"the second test image",
|
||||
"shape":"square",
|
||||
"size":"medium",
|
||||
"color":"#FF2717",
|
||||
"outlineColor":"#06A3B7",
|
||||
"outlineSize":"small",
|
||||
"text":"Some Text",
|
||||
"textSize":"medium",
|
||||
"textColor":"#009606",
|
||||
"textBackgroundColor":"#06A3B7",
|
||||
"textPosition":"below"
|
||||
}
|
||||
</metadata>
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
|
||||
</svg>`
|
||||
{
|
||||
"name":"Test Icon.svg",
|
||||
"alt":"the second test image",
|
||||
"shape":"square",
|
||||
"size":"medium",
|
||||
"color":"#FF2717",
|
||||
"outlineColor":"#06A3B7",
|
||||
"outlineSize":"small",
|
||||
"text":"Some Text",
|
||||
"textSize":"medium",
|
||||
"textColor":"#009606",
|
||||
"textBackgroundColor":"#06A3B7",
|
||||
"textPosition":"below"
|
||||
}`
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -707,47 +665,3 @@ describe('useSvgSettings()', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('svgFromUrl()', () => {
|
||||
let svgResponse
|
||||
|
||||
const subject = () => svgFromUrl('https://www.instructure.com/svg')
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mock('https://www.instructure.com/svg', () => ({
|
||||
body: svgResponse,
|
||||
sendAsJson: false
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore()
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('when the url points to an SVG file', () => {
|
||||
beforeEach(() => {
|
||||
svgResponse = `
|
||||
<svg height="100" width="100">
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
|
||||
it('returns the parsed SVG document', async () => {
|
||||
const svgDoc = await subject()
|
||||
expect(svgDoc.querySelector('svg').innerHTML).toContain(
|
||||
'<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the url points to a document that is not parsable', () => {
|
||||
beforeEach(() => (svgResponse = 'asdf'))
|
||||
|
||||
it('returns an empty document', async () => {
|
||||
const doc = await subject()
|
||||
expect(doc.firstChild.toString.innerHTML).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
import {useState, useEffect, useReducer} from 'react'
|
||||
import {svgSettings as svgSettingsReducer, defaultState} from '../reducers/svgSettings'
|
||||
import {ICON_MAKER_ATTRIBUTE, ICON_MAKER_DOWNLOAD_URL_ATTR, SVG_XML_TYPE} from './constants'
|
||||
import RceApiSource from '../../../../rcs/api'
|
||||
import {modes} from '../reducers/imageSection'
|
||||
import iconsLabels from '../utils/iconsLabels'
|
||||
|
||||
|
@ -56,22 +55,10 @@ const getImageNode = (editor, editing) => {
|
|||
return iconMaker
|
||||
}
|
||||
|
||||
const buildFilesUrl = (fileId, rcsConfig) => {
|
||||
// http://canvas.docker/files/2169/download?download_frd=1&icon_maker_icon=1
|
||||
|
||||
const downloadURL = new URL(`${rcsConfig.canvasUrl}/files/${fileId}/download`)
|
||||
|
||||
// Adding the Course ID to the request causes Canvas to follow the chain
|
||||
// of files that were uploaded and "replaced" previous versions of the file.
|
||||
downloadURL.searchParams.append('replacement_chain_context_type', 'course')
|
||||
downloadURL.searchParams.append('replacement_chain_context_id', rcsConfig.contextId)
|
||||
|
||||
// Prevent the browser from using an old cached SVGs
|
||||
downloadURL.searchParams.append('ts', Date.now())
|
||||
|
||||
// Yes, we want do download for real dude
|
||||
downloadURL.searchParams.append('download_frd', 1)
|
||||
const buildMetadataUrl = (fileId, rcsConfig) => {
|
||||
// http://canvas.docker/api/v1/files/2169/icon_metadata
|
||||
|
||||
const downloadURL = new URL(`${rcsConfig.canvasUrl}/api/v1/files/${fileId}/icon_metadata`)
|
||||
return downloadURL.toString()
|
||||
}
|
||||
|
||||
|
@ -97,25 +84,15 @@ export function useSvgSettings(editor, editing, rcsConfig) {
|
|||
// Parse out the file ID from something like
|
||||
// /courses/1/files/3/preview?...
|
||||
const fileId = urlFromNode.split('files/')[1]?.split('/')[0]
|
||||
const downloadUrl = buildFilesUrl(fileId, rcsConfig)
|
||||
const downloadUrl = buildMetadataUrl(fileId, rcsConfig)
|
||||
|
||||
// Parse SVG. If no SVG found, return defaults
|
||||
const svg = await svgFromUrl(downloadUrl)
|
||||
if (!svg) return
|
||||
|
||||
// Parse metadata. If no metadata found, return defaults
|
||||
const metadata = svg.querySelector('metadata')?.innerHTML
|
||||
// Download icon metadata. If no metadata found, return defaults
|
||||
const response = await fetch(downloadUrl)
|
||||
const metadata = await response.text()
|
||||
if (!metadata) return
|
||||
|
||||
const rcs = new RceApiSource(rcsConfig)
|
||||
|
||||
const fileData = await rcs.getFile(fileId, {
|
||||
replacement_chain_context_type: rcsConfig.contextType,
|
||||
replacement_chain_context_id: rcsConfig.contextId
|
||||
})
|
||||
const fileName = fileData.name.replace(/\.[^\.]+$/, '')
|
||||
|
||||
const metadataJson = JSON.parse(metadata)
|
||||
const fileName = metadataJson.name.replace(/\.[^\.]+$/, '')
|
||||
metadataJson.name = fileName
|
||||
metadataJson.originalName = fileName
|
||||
|
||||
|
@ -152,13 +129,6 @@ export function useSvgSettings(editor, editing, rcsConfig) {
|
|||
return [settings, status, dispatch]
|
||||
}
|
||||
|
||||
export async function svgFromUrl(url) {
|
||||
const response = await fetch(url)
|
||||
|
||||
const data = await response.text()
|
||||
return new DOMParser().parseFromString(data, SVG_XML_TYPE)
|
||||
}
|
||||
|
||||
function processMetadataForBackwardCompatibility(metadataJson) {
|
||||
const icon = metadataJson?.imageSettings?.icon
|
||||
const mode = metadataJson?.imageSettings?.mode
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
require_relative "../api_spec_helper"
|
||||
require_relative "../locked_examples"
|
||||
require "webmock/rspec"
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.include ApplicationHelper
|
||||
|
@ -1410,6 +1411,102 @@ describe "Files API", type: :request do
|
|||
end
|
||||
end
|
||||
|
||||
describe "#icon_metadata" do
|
||||
context "instfs file" do
|
||||
before do
|
||||
@root = Folder.root_folders(@course).first
|
||||
@icon = Attachment.create!(filename: "icon.svg", display_name: "icon.svg", uploaded_data: File.open("spec/fixtures/icon.svg"),
|
||||
folder: @root, context: @course, category: Attachment::ICON_MAKER_ICONS, instfs_uuid: "yes")
|
||||
@file_path = "/api/v1/files/#{@icon.id}/icon_metadata"
|
||||
@file_path_options = { controller: "files", action: "icon_metadata", format: "json", id: @icon.id.to_param }
|
||||
allow(InstFS).to receive(:authenticated_url).and_return(@icon.authenticated_s3_url)
|
||||
allow(CanvasHttp).to receive(:validate_url).and_return([@icon.authenticated_s3_url, URI.parse(@icon.authenticated_s3_url)])
|
||||
stub_request(:get, @icon.authenticated_s3_url).to_return(body: File.open("spec/fixtures/icon.svg"))
|
||||
end
|
||||
|
||||
it "returns metadata from the icon" do
|
||||
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 200)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["type"]).to eq "image/svg+xml-icon-maker-icons"
|
||||
expect(json["encodedImage"]).to be_starts_with "data:image/svg+xml;base64,PHN2ZyB3aWR0aD"
|
||||
end
|
||||
|
||||
it "gives unauthorized errors if the user is not authorized to view the file" do
|
||||
@icon.update(locked: true)
|
||||
course_with_student_logged_in(course: @course)
|
||||
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 401)
|
||||
end
|
||||
|
||||
it "gives bad request errors if the file is not an icon" do
|
||||
@icon.update(category: Attachment::UNCATEGORIZED)
|
||||
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 400)
|
||||
end
|
||||
|
||||
it "return 'no content' if the file doesn't have any metadata" do
|
||||
stub_request(:get, @icon.public_url).to_return(body: "<html>something that doesn't have any metadata</html>")
|
||||
raw_api_call(:get, @file_path, @file_path_options)
|
||||
assert_status(204)
|
||||
end
|
||||
|
||||
context "streaming" do
|
||||
before do
|
||||
# force chunking so streaming will actually act like a stream
|
||||
mocked_http = Class.new(Net::HTTP) do
|
||||
def request(*)
|
||||
super do |response|
|
||||
response.instance_eval do
|
||||
def read_body(*, &block)
|
||||
@body.each_char(&block)
|
||||
end
|
||||
end
|
||||
yield response if block_given?
|
||||
response
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@original_net_http = Net.send(:remove_const, :HTTP)
|
||||
Net.send(:const_set, :HTTP, mocked_http)
|
||||
end
|
||||
|
||||
after do
|
||||
Net.send(:remove_const, :HTTP)
|
||||
Net.send(:const_set, :HTTP, @original_net_http)
|
||||
end
|
||||
|
||||
it "only downloads data until the end of the metadata tag" do
|
||||
# I cut most of the original icon file off so that the XML is invalid if you read the whole thing,
|
||||
# but left enough that the metadata will be present and there will be a buffer for the http request
|
||||
# to read without erroring unless it downloads/parses too much of the file
|
||||
stub_request(:get, @icon.public_url).to_return(body: File.open("spec/fixtures/icon_with_bad_xml.svg"))
|
||||
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 200)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["type"]).to eq "image/svg+xml-icon-maker-icons"
|
||||
expect(json["encodedImage"]).to be_starts_with "data:image/svg+xml;base64,PHN2ZyB3aWR0aD"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "local file" do
|
||||
before do
|
||||
@root = Folder.root_folders(@course).first
|
||||
@icon = Attachment.create!(filename: "icon.svg", display_name: "icon.svg", uploaded_data: File.open("spec/fixtures/icon.svg"),
|
||||
folder: @root, context: @course, category: Attachment::ICON_MAKER_ICONS)
|
||||
@file_path = "/api/v1/files/#{@icon.id}/icon_metadata"
|
||||
@file_path_options = { controller: "files", action: "icon_metadata", format: "json", id: @icon.id.to_param }
|
||||
allow(CanvasHttp).to receive(:validate_url).and_return([@icon.authenticated_s3_url, URI.parse(@icon.authenticated_s3_url)])
|
||||
stub_request(:get, @icon.authenticated_s3_url).to_return(body: File.open("spec/fixtures/icon.svg"))
|
||||
end
|
||||
|
||||
it "returns metadata from the icon" do
|
||||
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 200)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["type"]).to eq "image/svg+xml-icon-maker-icons"
|
||||
expect(json["encodedImage"]).to be_starts_with "data:image/svg+xml;base64,PHN2ZyB3aWR0aD"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#reset_verifier" do
|
||||
before :once do
|
||||
@root = Folder.root_folders(@course).first
|
||||
|
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 249 KiB |
|
@ -0,0 +1,20 @@
|
|||
<svg fill="none" height="164px" viewBox="0 0 122 164" width="122px" xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>{"type":"image/svg+xml-icon-maker-icons","alt":"","shape":"square","size":"small","color":"#FFFFFF","outlineColor":"#65499D","outlineSize":"large","text":"Hello","textSize":"x-large","textColor":"#65499D","textBackgroundColor":"#FFFFFF","textPosition":"bottom-third","encodedImage":"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgwIiBoZWlnaHQ9IjQ4MCIgdmlld0JveD0iMCAwIDQ4MCA0ODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICAgIDxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF81MDdfMzMwKSI+CiAgICAgICAgPHBhdGggZD0iTTI0NS4yMjQgMTE1LjI2NEMyNDQuNTcyIDExNS45NSAyNDMuNzg3IDExNi40OTYgMjQyLjkxNyAxMTYuODdDMjQyLjA0OCAxMTcuMjQzIDI0MS4xMTEgMTE3LjQzNiAyNDAuMTY0IDExNy40MzZDMjM5LjIxOCAxMTcuNDM2IDIzOC4yODEgMTE3LjI0MyAyMzcuNDExIDExNi44N0MyMzYuNTQyIDExNi40OTYgMjM1Ljc1NyAxMTUuOTUgMjM1LjEwNSAxMTUuMjY0QzE1My4yMDggMjkuNTc3MSA1Ni4wMzE5IDExNS4yNjQgODUuMzQ3NiAyMTAuMjQzQzg1Ljk1NTkgMjEwLjA0NyA4Ni41OTEgMjA5Ljk0NyA4Ny4yMzAyIDIwOS45NDZIMTI4LjM1M0wxNDIuNTM4IDEzOS4yNDJDMTQyLjgyNyAxMzcuNzkzIDE0My42MjYgMTM2LjQ5NiAxNDQuNzkgMTM1LjU4NkMxNDUuOTU0IDEzNC42NzUgMTQ3LjQwNiAxMzQuMjEzIDE0OC44ODIgMTM0LjI4MkMxNTAuMzU4IDEzNC4zNTIgMTUxLjc2IDEzNC45NDggMTUyLjgzNCAxMzUuOTYzQzE1My45MDggMTM2Ljk3OCAxNTQuNTgyIDEzOC4zNDUgMTU0LjczNCAxMzkuODE1TDE2NS43MDQgMjQ0LjkwNkwxNzkuOTk1IDE3My44QzE4MC4yODEgMTcyLjQxNCAxODEuMDMzIDE3MS4xNjggMTgyLjEyNSAxNzAuMjY4QzE4My4yMTcgMTY5LjM2OSAxODQuNTg0IDE2OC44NyAxODUuOTk5IDE2OC44NTVIMTg2LjA1NkMxODcuNDYyIDE2OC44NjEgMTg4LjgyNCAxNjkuMzQ0IDE4OS45MiAxNzAuMjI0QzE5MS4wMTYgMTcxLjEwNSAxOTEuNzgxIDE3Mi4zMzIgMTkyLjA4OCAxNzMuNzA0TDIwMi4xNjUgMjE5LjY0NUwyMTAuMTk0IDE5Mi45ODlDMjEwLjU1OCAxOTEuNzc3IDIxMS4yODYgMTkwLjcwNiAyMTIuMjggMTg5LjkyMkMyMTMuMjczIDE4OS4xMzggMjE0LjQ4MyAxODguNjc4IDIxNS43NDcgMTg4LjYwNEMyMTcuMDEgMTg4LjUzIDIxOC4yNjYgMTg4Ljg0NyAyMTkuMzQzIDE4OS41MUMyMjAuNDIxIDE5MC4xNzQgMjIxLjI2OSAxOTEuMTUzIDIyMS43NzEgMTkyLjMxNEwyMjkuNDA0IDIwOS45NDZIMjY5LjEyOUMyNzAuNDk3IDIwNy40OSAyNzIuNjQzIDIwNS41NTggMjc1LjIyOCAyMDQuNDUyQzI3Ny44MTMgMjAzLjM0NyAyODAuNjkyIDIwMy4xMzEgMjgzLjQxMyAyMDMuODM4QzI4Ni4xMzUgMjA0LjU0NSAyODguNTQ0IDIwNi4xMzUgMjkwLjI2NCAyMDguMzU5QzI5MS45ODUgMjEwLjU4MyAyOTIuOTE4IDIxMy4zMTUgMjkyLjkxOCAyMTYuMTI3QzI5Mi45MTggMjE4LjkzOSAyOTEuOTg1IDIyMS42NzEgMjkwLjI2NCAyMjMuODk1QzI4OC41NDQgMjI2LjExOSAyODYuMTM1IDIyNy43MSAyODMuNDEzIDIyOC40MTdDMjgwLjY5MiAyMjkuMTI0IDI3Ny44MTMgMjI4LjkwOCAyNzUuMjI4IDIyNy44MDJDMjcyLjY0MyAyMjYuNjk3IDI3MC40OTcgMjI0Ljc2NCAyNjkuMTI5IDIyMi4zMDhIMjI1LjM2QzIyNC4xNTkgMjIyLjMwNiAyMjIuOTg0IDIyMS45NTQgMjIxLjk4IDIyMS4yOTRDMjIwLjk3NiAyMjAuNjM0IDIyMC4xODcgMjE5LjY5NSAyMTkuNzA5IDIxOC41OTNMMjE3LjE2NiAyMTIuNzI2TDIwNy4xNTIgMjQ1Ljk1OUMyMDYuNzU5IDI0Ny4yNjkgMjA1Ljk0MiAyNDguNDExIDIwNC44MjggMjQ5LjIwN0MyMDMuNzE1IDI1MC4wMDIgMjAyLjM2OSAyNTAuNDA1IDIwMS4wMDIgMjUwLjM1M0MxOTkuNjM1IDI1MC4zIDE5OC4zMjQgMjQ5Ljc5NSAxOTcuMjc1IDI0OC45MTZDMTk2LjIyNiAyNDguMDM4IDE5NS40OTkgMjQ2LjgzNiAxOTUuMjA3IDI0NS40OTlMMTg2LjMxNCAyMDUuMDQ3TDE2OS44MjYgMjg3LjA4NUMxNjkuNTQ5IDI4OC40ODMgMTY4Ljc5NCAyODkuNzQxIDE2Ny42OTEgMjkwLjY0M0MxNjYuNTg3IDI5MS41NDYgMTY1LjIwNSAyOTIuMDM2IDE2My43NzkgMjkyLjAzSDE2My40NzlDMTYyLjAwMiAyOTEuOTY4IDE2MC41OTYgMjkxLjM3NCAxNTkuNTIxIDI5MC4zNTlDMTU4LjQ0NiAyODkuMzQzIDE1Ny43NzMgMjg3Ljk3NCAxNTcuNjI3IDI4Ni41MDJMMTQ2LjY3NyAxODEuNDY0TDEzOS40NjkgMjE3LjMzOUMxMzkuMTgzIDIxOC43MzQgMTM4LjQyNCAyMTkuOTg3IDEzNy4zMjEgMjIwLjg4OEMxMzYuMjE4IDIyMS43ODkgMTM0LjgzOSAyMjIuMjgyIDEzMy40MTUgMjIyLjI4M0g4OS43MzA4Qzk1LjUwOTIgMjM2LjAxNiAxMDQuMDE4IDI0OS43OCAxMTUuNjczIDI2My4xMjFMMjMyLjIzIDM5Ni4zODdDMjMzLjIxMyAzOTcuNTIxIDIzNC40MjkgMzk4LjQzMSAyMzUuNzk1IDM5OS4wNTRDMjM3LjE2MSAzOTkuNjc4IDIzOC42NDUgNDAwIDI0MC4xNDcgNDAwQzI0MS42NDggNDAwIDI0My4xMzIgMzk5LjY3OCAyNDQuNDk4IDM5OS4wNTRDMjQ1Ljg2NCAzOTguNDMxIDI0Ny4wOCAzOTcuNTIxIDI0OC4wNjQgMzk2LjM4N0wzNjQuNjIgMjYzLjEyMUM0NjAuMzY2IDE1My42NjcgMzQxLjc5MyAxNC4zMzY0IDI0NS4yMjQgMTE1LjI2NFoiIGZpbGw9IiMwMDAwMDAiLz4KICAgICAgPC9nPgogICAgICA8ZGVmcz4KICAgICAgPGNsaXBQYXRoIGlkPSJjbGlwMF81MDdfMzMwIj4KICAgICAgICA8cmVjdCB3aWR0aD0iMzIwIiBoZWlnaHQ9IjMyMCIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDgwIDgwKSIvPgogICAgICA8L2NsaXBQYXRoPgogICAgICA8L2RlZnM+CiAgICA8L3N2Zz4KICAgIA==","encodedImageType":"SingleColor","encodedImageName":"Health Icon","x":"50%","y":"50%","translateX":-54,"translateY":-54,"width":108,"height":108,"transform":"translate(-54,-54)"}
|
||||
</metadata>
|
||||
<svg fill="none" height="122px" viewBox="0 0 122 122" width="122px" x="0">
|
||||
<g fill="#FFFFFF" stroke="#65499D" stroke-width="8">
|
||||
<clipPath id="clip-path-for-embed">
|
||||
<rect height="114" width="114" x="4" y="4"/>
|
||||
</clipPath>
|
||||
<rect height="114" width="114" x="4" y="4"/>
|
||||
<g clip-path="url(#clip-path-for-embed)">
|
||||
<image height="108" href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgwIiBoZWlnaHQ9IjQ4MCIgdmlld0JveD0iMCAwIDQ4MCA0ODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICAgIDxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF81MDdfMzMwKSI+CiAgICAgICAgPHBhdGggZD0iTTI0NS4yMjQgMTE1LjI2NEMyNDQuNTcyIDExNS45NSAyNDMuNzg3IDExNi40OTYgMjQyLjkxNyAxMTYuODdDMjQyLjA0OCAxMTcuMjQzIDI0MS4xMTEgMTE3LjQzNiAyNDAuMTY0IDExNy40MzZDMjM5LjIxOCAxMTcuNDM2IDIzOC4yODEgMTE3LjI0MyAyMzcuNDExIDExNi44N0MyMzYuNTQyIDExNi40OTYgMjM1Ljc1NyAxMTUuOTUgMjM1LjEwNSAxMTUuMjY0QzE1My4yMDggMjkuNTc3MSA1Ni4wMzE5IDExNS4yNjQgODUuMzQ3NiAyMTAuMjQzQzg1Ljk1NTkgMjEwLjA0NyA4Ni41OTEgMjA5Ljk0NyA4Ny4yMzAyIDIwOS45NDZIMTI4LjM1M0wxNDIuNTM4IDEzOS4yNDJDMTQyLjgyNyAxMzcuNzkzIDE0My42MjYgMTM2LjQ5NiAxNDQuNzkgMTM1LjU4NkMxNDUuOTU0IDEzNC42NzUgMTQ3LjQwNiAxMzQuMjEzIDE0OC44ODIgMTM0LjI4MkMxNTAuMzU4IDEzNC4zNTIgMTUxLjc2IDEzNC45NDggMTUyLjgzNCAxMzUuOTYzQzE1My45MDggMTM2Ljk3OCAxNTQuNTgyIDEzOC4zNDUgMTU0LjczNCAxMzkuODE1TDE2NS43MDQgMjQ0LjkwNkwxNzkuOTk1IDE3My44QzE4MC4yODEgMTcyLjQxNCAxODEuMDMzIDE3MS4xNjggMTgyLjEyNSAxNzAuMjY4QzE4My4yMTcgMTY5LjM2OSAxODQuNTg0IDE2OC44NyAxODUuOTk5IDE2OC44NTVIMTg2LjA1NkMxODcuNDYyIDE2OC44NjEgMTg4LjgyNCAxNjkuMzQ0IDE4OS45MiAxNzAuMjI0QzE5MS4wMTYgMTcxLjEwNSAxOTEuNzgxIDE3Mi4zMzIgMTkyLjA4OCAxNzMuNzA0TDIwMi4xNjUgMjE5LjY0NUwyMTAuMTk0IDE5Mi45ODlDMjEwLjU1OCAxOTEuNzc3IDIxMS4yODYgMTkwLjcwNiAyMTIuMjggMTg5LjkyMkMyMTMuMjczIDE4OS4xMzggMjE0LjQ4MyAxODguNjc4IDIxNS43NDcgMTg4LjYwNEMyMTcuMDEgMTg4LjUzIDIxOC4yNjYgMTg4Ljg0NyAyMTkuMzQzIDE4OS41MUMyMjAuNDIxIDE5MC4xNzQgMjIxLjI2OSAxOTEuMTUzIDIyMS43NzEgMTkyLjMxNEwyMjkuNDA0IDIwOS45NDZIMjY5LjEyOUMyNzAuNDk3IDIwNy40OSAyNzIuNjQzIDIwNS41NTggMjc1LjIyOCAyMDQuNDUyQzI3Ny44MTMgMjAzLjM0NyAyODAuNjkyIDIwMy4xMzEgMjgzLjQxMyAyMDMuODM4QzI4Ni4xMzUgMjA0LjU0NSAyODguNTQ0IDIwNi4xMzUgMjkwLjI2NCAyMDguMzU5QzI5MS45ODUgMjEwLjU4MyAyOTIuOTE4IDIxMy4zMTUgMjkyLjkxOCAyMTYuMTI3QzI5Mi45MTggMjE4LjkzOSAyOTEuOTg1IDIyMS42NzEgMjkwLjI2NCAyMjMuODk1QzI4OC41NDQgMjI2LjExOSAyODYuMTM1IDIyNy43MSAyODMuNDEzIDIyOC40MTdDMjgwLjY5MiAyMjkuMTI0IDI3Ny44MTMgMjI4LjkwOCAyNzUuMjI4IDIyNy44MDJDMjcyLjY0MyAyMjYuNjk3IDI3MC40OTcgMjI0Ljc2NCAyNjkuMTI5IDIyMi4zMDhIMjI1LjM2QzIyNC4xNTkgMjIyLjMwNiAyMjIuOTg0IDIyMS45NTQgMjIxLjk4IDIyMS4yOTRDMjIwLjk3NiAyMjAuNjM0IDIyMC4xODcgMjE5LjY5NSAyMTkuNzA5IDIxOC41OTNMMjE3LjE2NiAyMTIuNzI2TDIwNy4xNTIgMjQ1Ljk1OUMyMDYuNzU5IDI0Ny4yNjkgMjA1Ljk0MiAyNDguNDExIDIwNC44MjggMjQ5LjIwN0MyMDMuNzE1IDI1MC4wMDIgMjAyLjM2OSAyNTAuNDA1IDIwMS4wMDIgMjUwLjM1M0MxOTkuNjM1IDI1MC4zIDE5OC4zMjQgMjQ5Ljc5NSAxOTcuMjc1IDI0OC45MTZDMTk2LjIyNiAyNDguMDM4IDE5NS40OTkgMjQ2LjgzNiAxOTUuMjA3IDI0NS40OTlMMTg2LjMxNCAyMDUuMDQ3TDE2OS44MjYgMjg3LjA4NUMxNjkuNTQ5IDI4OC40ODMgMTY4Ljc5NCAyODkuNzQxIDE2Ny42OTEgMjkwLjY0M0MxNjYuNTg3IDI5MS41NDYgMTY1LjIwNSAyOTIuMDM2IDE2My43NzkgMjkyLjAzSDE2My40NzlDMTYyLjAwMiAyOTEuOTY4IDE2MC41OTYgMjkxLjM3NCAxNTkuNTIxIDI5MC4zNTlDMTU4LjQ0NiAyODkuMzQzIDE1Ny43NzMgMjg3Ljk3NCAxNTcuNjI3IDI4Ni41MDJMMTQ2LjY3NyAxODEuNDY0TDEzOS40NjkgMjE3LjMzOUMxMzkuMTgzIDIxOC43MzQgMTM4LjQyNCAyMTkuOTg3IDEzNy4zMjEgMjIwLjg4OEMxMzYuMjE4IDIyMS43ODkgMTM0LjgzOSAyMjIuMjgyIDEzMy40MTUgMjIyLjI4M0g4OS43MzA4Qzk1LjUwOTIgMjM2LjAxNiAxMDQuMDE4IDI0OS43OCAxMTUuNjczIDI2My4xMjFMMjMyLjIzIDM5Ni4zODdDMjMzLjIxMyAzOTcuNTIxIDIzNC40MjkgMzk4LjQzMSAyMzUuNzk1IDM5OS4wNTRDMjM3LjE2MSAzOTkuNjc4IDIzOC42NDUgNDAwIDI0MC4xNDcgNDAwQzI0MS42NDggNDAwIDI0My4xMzIgMzk5LjY3OCAyNDQuNDk4IDM5OS4wNTRDMjQ1Ljg2NCAzOTguNDMxIDI0Ny4wOCAzOTcuNTIxIDI0OC4wNjQgMzk2LjM4N0wzNjQuNjIgMjYzLjEyMUM0NjAuMzY2IDE1My42NjcgMzQxLjc5MyAxNC4zMzY0IDI0NS4yMjQgMTE1LjI2NFoiIGZpbGw9IiMwMDAwMDAiLz4KICAgICAgPC9nPgogICAgICA8ZGVmcz4KICAgICAgPGNsaXBQYXRoIGlkPSJjbGlwMF81MDdfMzMwIj4KICAgICAgICA8cmVjdCB3aWR0aD0iMzIwIiBoZWlnaHQ9IjMyMCIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDgwIDgwKSIvPgogICAgICA8L2NsaXBQYXRoPgogICAgICA8L2RlZnM+CiAgICA8L3N2Zz4KICAgIA==" transform="translate(-54,-54)" width="108" x="50%" y="50%"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<path d="M28,102 h66 a4,4 0 0 1 4,4 v30 a4,4 0 0 1 -4,4 h-66 a4,4 0 0 1 -4,-4 v-30 a4,4 0 0 1 4,-4 z" fill="#FFFFFF"/>
|
||||
<text fill="#65499D" font-family="Lato Extended" font-size="28" font-weight="bold" x="28" y="132">
|
||||
<tspan dy="0" x="28">Hello</tspan>
|
||||
</text>
|
||||
<style type="text/css">@font-face {font-family: "Lato Extended";font-weight: bold;src: url(data:font/woff2;base64,d09GMgABAAAAAtJQABEAAAAJKxgAAtHnAAID1wAAAAAAAAAAAAAAAAAAAAAAAAAAG439fhzQZgZgAKskCIFmCZcXEQgKjf1Ei8c+ATYCJAPePBOEgAIL3kAABCAFrhkHgdRvDIE5W6Nf+Hr/v3dq73oytiXNJOOJZCfSOlIVlcHuOSEqAHqXXcBFcLrsFIiW6Bvxe/kLwdUQ/5sqALtqmjav/26XPRAyZHbk22RBOpmUbg9l09dpXw8JOySg2nW7fykX/m2MvQ845sDITtTOsmt4Wy8qREDZ/////////////////////////////////////////////////////////////////////////////////////+WH/L39zMlNJ7l6RMlFoQWr9ZdIEmQMWXKFeb7vBRSS50V88l3mRm0snvBdnweuG3OFkhS6NnCZpITSKJM1lBOSIO/6ge8XikGQKpVjlVVBEAuqa2p98mN19dTgeZRv5DZFxYI0NbvlcgsTaXWtpRbWFrXMkmSNZ8kYsb4hV4yIkCskFPMjriusnToikURnhrqifnWMTar10ilvcjoMwBSaanhDN1nLezyylmKxZNF29frURxFy+yMhaGADCRrkQ3A4ASWBREWQKIWTKAZS06ZngfAZFEbgTEFiDDIkSJSCaGP1LBbMnlNsbaPy3HlgfgpH00hoBFkSJKPNyFBnaqyq2ArGO2mCLViIEmwRF8o00OL5tITGaSkPqIOWcc8ylxfZcm3Lp0ZpYLhMMiRMSsNuhxQWKDcLXKHaUDMXvCKtlMnSyrQKGgYuaxEKlOMqjWhVbKmee6u1r15Ca1QZ6pcylaeSVBe8dvIo73u0psvc8Wpaq9Ejz/PYiJdx+VLXpmJUkLXZvHUkbVhNkoxwU4XWNXBmr0FmvR6UzoeU4j6lKU3rp9gGGw4L32iMTQgzcVTOGmQNFDJISJCwWK/IxmCTKrQp2syBCBKHDI8OQXfO6sBasPkWw1u6me5ydr2gJqCtQnLJ0taUoYzrZoqxAUn6WYs8to3CtrQtXwbbdaqmwnZFWrQ9FIVOJEEOjc4xyFXagTJk1XZEgYMGiVr9TnmcYtGpcKQehSTICBSKICGDhKrXJJ/tzJK7UOcwmydgKA5jxqAllEBCAqxhuy7ilWwa7bY52L0kYBjuMQdJVQoJCRIHdlmlvo66YmqNjhVQAwlqVlxrT75XiygJ83RJkrVMFPYAe4+sA5qhu4+gRh3aWtA4srhVcBUSbMAWZAwo8432pRntbcx4aK/lZ6P9KIGEhAaZZy0UEjavf3+X5aNM+AEHztV9Ec09qIrPDIV39EKBXQdnYVGQSyNIqKuTIuwQFNc6CKCkTCJXQ57CYnGystK3JGFABs/uAkaAB5vTBuVJgGUCphiWjFEtWZYa3ybeDwO8/aGarpUBo4cdrjg1i1IqWTSOXZ4M0RGb0GzmTasakeE1KaHQiSRIAVEwdbyRWTNCjUfSUY1KgWMHoVBxH2wdqNbsaPVjKuBjlccq4iH9XQf21mEnJdF/J/Ut9N/JoW5HjzveiQG1slPrK51A+1Ba94060WoqG/coNeGogyeedPJhFcc+uKleg2lKzeqn6D+/4o2qbI1OZYc6swJMOXLa6ccpnqHtmaOnj4/vSGPVFnaD1rPIRUICjCEDVjemcvrZzPdybutSWnrwORT1PaH0BglyXRaQhfGzDZJzDTICRaAkfIXjCofTXN7RseyMNC1UPo/i6HxpQxN4XYrSnlScvCHbXiW5Tp0jcVRNgpqxoQuOBbtr3XThRXDfizeGm4I4LItqKDBBgi4hj+aLJ/tTB2/lslphMq+Lwa0EbXepQZZCJGSQ7DmFlvUbflmSLr+CX8kv24zFVYskSOhIdChei666WgQdyK+h5CxwLRO2AxCys+Eih2ePzAPrGZoOrjMGiuuRZaFYAS3GiJBHrgHGZGjI2P3NCF1ryQLXCGuQluP4oRRcb5llwkOf8pb6hc0moblxH9itDEsIu+GyKmNuZAlb9IPJMYr4ctPN3bcwzzt2eWphrhuPx29lnkdB7jbmugs8ukRpGvf2o5Xs2Ud28GAQzhFVIR8JCZKTB2DYgvp43/bHzmCdsEejK9iNDezKk6pWXat0u0q0pB5HRuGO1dWmanrnXZvebcDRk/12uMpkR5KqWZWJRjBN6Sydd9bWLoMzFALdrEKDvhvCM+j89Jl8M7Y361bIYFG9uk7hnntJ4O3abnjhfQLu1LeV7TqueTtMUW571j0131ICx6ve4pdUi0qX0v0P4BwYhPMMNCLcd9aorIGX39LRBrSPyoMPycOPGLULdZ1zEIzScZdcZ92dD0jiEfwgFJimK5HQxLkV/6P6n7+q/
|
||||
|
After Width: | Height: | Size: 11 KiB |
Loading…
Reference in New Issue